Home > Back-end >  How to relate different parameters linked by a generic property
How to relate different parameters linked by a generic property

Time:04-16

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?

TypeScript playground.

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!


Playground link to code

  • Related