[SOLVED] Typescript flow analysis not working for dictionary of functions whose type depends on the key

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.

Playground link to code

Answered By – jcalz

Answer Checked By – Pedro (BugsFixing Volunteer)

Leave a Reply

Your email address will not be published. Required fields are marked *