I am in the following context:
type NumericLiteral = {
value: number;
type: "NumericLiteral";
};
type StringLiteral = {
value: string;
type: "StringLiteral";
};
type Identifier = {
name: string;
type: "Identifier";
};
type CallExpression = {
name: string;
arguments: DropbearNode[];
type: "CallExpression";
};
type DropbearNode =
| NumericLiteral
| StringLiteral
| Identifier
| CallExpression;
type Visitor = {
[K in DropbearNode["type"]]: (node: Readonly<Extract<DropbearNode, { type: K }>>) => void
};
I'd like to type a function visitNode
that given a DropbearNode
node n
and a Visitor
v
is at least able to call v[n.type]
on n
: v[n.type](n)
.
My first attempt was:
function visitNode<N extends DropbearNode>(node: Readonly<N>, v: Visitor) {
v[node.type](node)
}
but it seems that TS considers v[node.type]
and node
completely unrelated, and I'm not sure I understand exactly what goes wrong. I mean, node.type
becames N["type"]
that is considered equal to the full union "NumericLiteral" | "StringLiteral" | "Identifier" | "CallExpression"
, so at the end I think it expects as an argument an intersection of all the nodes' types, which is never
of course because they form a discriminated union. But why TS expands N["type"]
like that? What is going on here?
My second attempt was:
function visitNode<K extends DropbearNode['type']>(node: Readonly<Extract<DropbearNode, { type: K }>>, v: Visitor) {
v[node.type](node)
}
in which I tried to impose a link between the two things. It seemed like a good idea, but I encountered a design limitation so I am forced to cast the node.type
as K
because TS is not able to reduce typeof node.type
to it.
So, how could I properly type this function? And what are the reasons that make the first attempt wrong?
CodePudding user response:
Inside the implementation of visitNode()
, both v[node.type]
and node
are of union types or generic types constrained to such a union.
Let's abbreviate NumericLiteral
, etc. to NL
, SL
, etc., and (t: T)=>void
as Fn<T>
. Then v[node.type]
is of a type assignable to Fn<NL> | Fn<SL> | Fn<I> | Fn<CE>
and node
is of a type assignable to NL | SL | I | CE
. If that's all the compiler knows about the types of v[node.type]
and node
, then it will not let you call v[node.type](node)
without complaint. As you know, a union of function types can only safely accept the intersection of its parameter types (at least as of TypeScript 3.3's introduction of improved support for calling union types), and NL | SL | I | CE
is not assignable to NL & SL & I & CE
, so there's an error.
Such an error would be perfectly reasonable if you wrote v[node1.type](node2)
where node1
and node2
were both of a generic type constrained to NL | SL | I | CE
. Maybe node1
is of type NL
but node2
is of type SL
. In that case, v[node1.type]
and node2
would be of independent union types.
But that's not what's going on, is it? v[node.type]
and node
are obviously correlated to each other. If node
is of type NL
then v[node.type]
is of type Fn<NL>
. It is always safe to call v[node.type](node)
, but the compiler just can't see it!
This is the issue that prompted be to file the feature request at microsoft/TypeScript#30581. Up to and including TypeScript 4.5, the only ways to deal with this situation were either to use a type assertion to just suppress the warning, or to write redundant code to make one line per possible type for node
. Neither was great.
Luckily, TypeScript 4.6 introduced improvements to generic indexed access inference as implemented in microsoft/TypeScript#47109 to address this.
The trick is to write a mapping type whose keys are DropbearNode['type']
and whose properties are the associated node
types:
type TypeMap = { [K in DropbearNode["type"]]: Extract<DropbearNode, { type: K }> }
/* type TypeMap = {
NumericLiteral: NumericLiteral;
StringLiteral: StringLiteral;
Identifier: Identifier;
CallExpression: CallExpression;
} */
Then if we can represent the type of node
in terms of an indexed access into this map, and if we can represent the type of v
in terms of a similar mapped type whose properties are also in terms of the same indexed access, the compiler will primed to see the correlation:
type Visitor = {
[K in keyof TypeMap]: (node: Readonly<TypeMap[K]>) => void
};
function visitNode<K extends keyof TypeMap>(node: Readonly<TypeMap[K]>, v: Visitor) {
v[node.type](node) // okay
}
Hooray!
Beware, though... this is finicky/fragile. If you were to instead define Visitor
explicitly as a set of properties like:
type Visitor = {
NumericLiteral: (node: Readonly<NumericLiteral>) => void;
StringLiteral: (node: Readonly<StringLiteral>) => void;
Identifier: (node: Readonly<Identifier>) => void;
CallExpression: (node: Readonly<CallExpression>) => void;
}
the same error from before would come back. Even though this Visitor
is structurally identical to the mapped type, the compiler is no longer ready to pay attention to the correlation. The mapped type with TypeMap[K]
in it is crucial for this to work. So be careful!