Home > database >  Can generic indexed access parameters be constrained?
Can generic indexed access parameters be constrained?

Time:08-29

For the following type:

type Union =
  | {
      type: "string";
      payload: string;
    }
  | {
      type: "number";
      payload: number;
    };

I can create the following function:

const call1 = <T extends Union>(union: T) => {
  console.log(union.type, union.payload);
};

And calling it with a bad type throws an error:

call1({
  type: "number",
  payload: "hi", // Type 'string' is not assignable to type 'number'.ts(2345)
});

However, if I define the function using indexed access:

const call2 = <T extends Union>(type: T["type"], data: T["payload"]) => {
  console.log(type, data);
};

Now calling the function will not throw an error:

call2("number", "hi"); // no error

Is it possible to have the function with indexed access parameters constrain the type of the second parameter via the type of the first?

CodePudding user response:

The problem here is with generic type argument inference. In your version of call2(),

const call2 = <T extends Union>(type: T["type"], data: T["payload"]) => {
    console.log(type, data);
};

You are probably expecting the compiler to infer T from the fact that "number" is assignable to T["type"]. Unfortunately, this just doesn't happen. The compiler does not see T["type"] or T["payload"] as good inference sites for T. So inference fails and the type T falls back to its constraint, which is Union, as you can see with IntelliSense:

call2("number", "hi"); // no error
// const call2: <Union>(type: "string" | "number", data: string | number) => void

And since Union["type"] is "string" | "number", and Union["payload"] is string | number, the arguments "number" and "hi" are respectively assignable to each, and no error happens. Oops.

It's not unreasonable to expect inference to work in this case, and in fact such inference was implemented in microsoft/TypeScript#20126, but this feature was never merged.

In cases where generic type arguments aren't inferred the way you want, you could always manually specify them yourself, like this:

call2<{ type: "number", payload: number }>("number", "hi"); // error
// ------------------------------------------------> ~~~~

But presumably you want to get inference working for you.


The best way to have the compiler infer a type parameter for you is to give it a value of that type. I mean, instead of trying to infer T from a value of type T["type"], have it infer U from a value of type U. This means your type parameter should correspond to, say, the type argument of call2(), and then you can compute the type of data from that. Since your type values are of string literal types, it is easier to write the relationship between type and payload in terms of the relationship between an object key and its value. Like this:

type UnionMap = { [T in Union as T['type']]: T['payload'] }
/* type UnionMap = {
    string: string;
    number: number;
} */

The UnionMap type has your type as its keys and the corresponding payload as its corresponding values. And we computed it from Union using key remapping. And now the call signature for call2() can be changed to this:

const call2 = <K extends keyof UnionMap>(type: K, data: UnionMap[K]) => {
    console.log(type, data);
};

So the type argument is of generic type K, which should hopefully make inference succeed, and then the compiler can check the argument passed for data against UnionMap[K]. Let's see if it works:

call2("number", "hi"); // error
// -----------> ~~~~
// const call2: <"number">(type: "number", data: number) => void

Looks good. The type argument K is inferred as "number", and therefore data is expected to be of type number, to which "hi" is not assignable, and you get an error.


By the way, this isn't 100% foolproof. Just as before you could get the non-error to become an error by manually specifying the type argument to be one piece of the union yourself, you can get this error to become a non-error by specifying the type argument to be the full keyof UnionMap union yourself:

call2<keyof UnionMap>("number", "hi"); // okay
// const call2: <"string" | "number">(type: "string" | "number", data: string | number) => void

Since K is keyof UnionMap, then that again makes type of type "string" | "number" and data of type string | number, to which "number" and "hi" are respectively assignable.

Generally speaking, nobody ever does this sort of thing, but it does technically mean that the compiler cannot necessarily assume that K will be exactly one of "string" or "number". There is an open issue asking for that at microsoft/TypeScript#27808, but for now it's not part of the language.


Playground link to code

  • Related