Issue
I’m using typescript 4.6
In my project I have a type called FooterFormElement
which has a discriminant property type
type FooterFormElement = {type:"number",...}|{type:"button",...};
To instantiate these elements I have a dictionary called footerFormElementDictionary
:
footerFormElementDictionary:{
[key in FooterFormElement["type"]]: (element: FooterFormElement & { type: key }) => HTMLElement
}
The problem is, when I try to use this dictionary, like in the following example
this.footerFormElementDictionary[item.type](item)
I get the error: Argument of type 'FooterFormElement' is not assignable to parameter of type 'never'
It seems as if the typescript compiler can’t "unroll" the flow analysis for the different values of type
.
Any way I can fix this (apart from ugly non-type-safe workarounds).
Here is a shortened minimal reproduceable example:
type FooterFormData = { [k: string]: number };
type FooterFormElement = {
type: "button"; Action: (data: FooterFormData) => void;
} | {
type: "number"; label: string; Action?: (value: number) => void;
}
declare const footerFormElementDictionary: {
[K in FooterFormElement["type"]]: (
element: FooterFormElement & { type: K },
formData: FooterFormData
) => HTMLElement;
}
function MakeFooterForm(elements: FooterFormElement[]): HTMLElement[] {
let formData: FooterFormData = {};
return elements.map((item) =>
footerFormElementDictionary[item.type](item, formData)
);
}
Here is a reproduceable example showcasing the problem.
Solution
For concreteness and brevity I will specify your types slightly differently from in your external links:
type FooterFormElement =
{ type: "button"; buttonStuff: string } |
{ type: "number"; numberStuff: string };
declare const footerFormElementDictionary: {
[K in FooterFormElement["type"]]: (
element: Extract<FooterFormElement, { type: K }>
) => HTMLElement;
}
It seems like you should be able to grab any item
of type FooterFormElement
, and call footerFormElementDictionary[item.type](item)
. But, as you saw, the compiler is unable to verify that this is type safe:
function problem(item: FooterFormElement) {
const fn = footerFormElementDictionary[item.type];
return fn(item); // error!
// -----> ~~~~
// Argument of type 'FooterFormElement' is not assignable to parameter of type 'never'.
// The intersection '{ type: "number"; numberStuff: string; } &
// { type: "button"; buttonStuff: string; }' // was reduced to 'never'
}
Let’s use IntelliSense to see what types the compiler sees for fn
and item
:
/* const fn: (
(element: { type: "number"; numberStuff: string },
formData: FooterFormData) => HTMLElement
) | (
(element: { type: "button"; buttonStuff: string },
formData: FooterFormData) => HTMLElement
) */
/* (parameter) item:
{ type: "button"; buttonStuff: string; } |
{ type: "number"; numberStuff: string; } */
The type of fn
is a union of function types, and the type of item
is also a union. These types are not wrong, but they are not useful enough for your purposes. The compiler treats these unions as uncorrelated. The compiler only knows that fn
is either something that accepts a "button"
-like input, or something that types a "number"
-like input, but it doesn’t know which.
And so the only input it could safely accept would be something which is both "button"
– and "number"
-like. Which would be the intersection of the input types. And since the input types are members of a discriminated union, the intersection of two incompatible members is reduced to the never
type.
Again, the compiler is concerned that maybe fn
will accept a "number"
-like input but item
will turn out to be "button"
-like, or vice versa. This cannot happen because fn
is correlated to item
, but that correlation is not represented in the types of fn
and item
. This problem, where the compiler cannot verify that two union types are correlated, is the subject of microsoft/TypeScript#30581.
Before TypeScript 4.6 I would have told you to just use a type assertion to suppress the error, or to write some redundant code if you really care about type safety more than convenience. There wasn’t anything better.
TypeScript 4.6 introduced some indexed access improvements as implemented in microsoft/TypeScript#47109, which allow us to refactor the code to represent FooterFormElement
and footerFormElementDictionary
in terms of distributive object types, so that the problematic function call can succeed as a generic function.
Here’s the refactoring. First we take the original FooterFormElement
and refactored it to a key-value mapping type where the original type
property is now a key and the rest of the properties are the new value type:
interface FooterFormElementMap {
button: { buttonStuff: string },
number: { numberStuff: string }
}
Then we can write FooterFormElement
as a distributive object type, which is a mapped type that you immediately index into to get a union of all the property types:
type FooterFormElement<K extends keyof FooterFormElementMap =
keyof FooterFormElementMap> =
{ [P in K]: { type: P } & FooterFormElementMap[P] }[K];
If you just write FooterFormElement
you get something equivalent to the same union type as before:
type Test = FooterFormElement;
/* type Test =
({ type: "number"; } & { numberStuff: string; }) |
({ type: "button"; } & { buttonStuff: string; }) */
And if you write FooterFormElement<"button">
or FooterFormElement<"number">
you will get just the member of the union you care about. This lets us write FooterFormElement<K>
as a generic type.
Finally, we rewrite the type of footerFormElementDictionary
in terms of FooterFormElement<K>
for each K
in the keys of FooterFormElementMap
:
declare const footerFormElementDictionary: {
[K in keyof FooterFormElementMap]: (
element: FooterFormElement<K>
) => HTMLElement;
}
This representation turns out to be important, and is what allows the compiler to see the relationship between properties in footerFormElementDictionary
and values of type FooterFormElement
for generic K
.
And finally we can turn the problematic function from before into a generic working version:
function solution<K extends keyof FooterFormElementMap>(item: FooterFormElement<K>) {
const fn: (element: FooterFormElement<K>) => HTMLElement =
footerFormElementDictionary[item.type];
// item: FooterFormElement<K>
return fn(item) // okay!
}
This works because fn
is seen as a function which takes a FooterFormElement<K>
and item
is seen as a FooterFormElement<K>
. So if you have an array of FooterFormElement
, you can use map()
with a generic callback and it’ll just work:
function MakeFooterForm(elements: FooterFormElement[]): HTMLElement[] {
return elements.map(<K extends keyof FooterFormElementMap>(
item: FooterFormElement<K>) => footerFormElementDictionary[item.type](item) // 👍
);
}
Hooray!
Just to be painfully clear, if we rewrite footerFormElementDictionary
‘s type annotation as a structurally equivalent version that does not mention FooterFormElement<K>
, this will all break:
declare const badDictionary: {
button: (element: { type: "button"; } & { buttonStuff: string; }) => HTMLElement;
number: (element: { type: "number"; } & { numberStuff: string; }) => HTMLElement;
}
function problemAgain<K extends keyof FooterFormElementMap>(item: FooterFormElement<K>) {
return badDictionary[item.type](item); // error!
// ---------------------------> ~~~~
// Argument is not assignable to parameter of type 'never'
}
As you can see, the correlation between the function and the argument types is fragile, and depends on a connection between their type definitions. So be careful.
Answered By – jcalz
Answer Checked By – Pedro (BugsFixing Volunteer)