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.