The issue at hand revolves around inference of generic type arguments. Within your implementation of call2()
,
const call2 = <T extends Union>(type: T["type"], data: T["payload"]) => {
console.log(type, data);
};
You might be anticipating the compiler to automatically determine the type T
based on the fact that "number"
is valid for T["type"]
. However, this inference does not occur as expected. The compiler does not perceive T["type"]
or T["payload"]
as suitable points for inferring the type T
. Consequently, the inference fails and falls back to its specified constraint which is Union
, as evidenced by IntelliSense:
call2("number", "hi"); // no error
// const call2: <Union>(type: "string" | "number", data: string | number) => void
Given that Union["type"]
is
"string" | "number"
, and
Union["payload"]
is
string | number
, both
"number"
and
"hi"
are respectively compliant with the types, resulting in no errors. Oops.
While it may seem reasonable to expect automatic inference, a feature like this was proposed in microsoft/TypeScript#20126 but never made its way into the language.
In scenarios where generic type arguments lack desired inference behavior, manual specification becomes an option. For instance:
call2<{ type: "number", payload: number }>("number", "hi"); // error
// ------------------------------------------------> ~~~~
However, striving for successful inference is preferable.
To facilitate type parameter inference, consider offering the compiler a value of the respective type. Rather than relying on T["type"]
for inferring T
, allow it to infer U
from a value of type U
. Relate your type parameter to the type
argument within call2()
, deriving the type of data
accordingly. Utilize key remapping to establish this connection between keys and values:
type UnionMap = { [T in Union as T['type']]: T['payload'] }
/* type UnionMap = {
string: string;
number: number;
} */
The UnionMap
aligns type
keys with their corresponding payload
values derived from Union
via key remapping. With this structure, adjust the call signature for call2()
like so:
const call2 = <K extends keyof UnionMap>(type: K, data: UnionMap[K]) => {
console.log(type, data);
};
Now, the type
argument conforms to the generic type K
, enhancing the probability of successful inference, allowing the compiler to validate the passed argument against UnionMap[K]
. Let's test the results:
call2("number", "hi"); // error
// -----------> ~~~~
// const call2: <"number">(type: "number", data: number) => void
This outcome indicates a satisfying result with the type argument K
inferred as "number"
, thus expecting data
to be of type
number</code, exposing the error with <code>"hi"
.
It's worth noting that this method isn't entirely foolproof. Just as you can convert a non-error scenario into an error by manually specifying a specific type argument, the converse is possible too. By defining the type argument as 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
represents keyof UnionMap
, type
defaults to
"string" | "number"
and
data
to
string | number
, accommodating the assignments of
"number"
and
"hi"
. This aspect highlights a limitation addressed in an ongoing discussion mentioned in
microsoft/TypeScript#27808, yet remains absent from the current language implementation.
Playground link to code