2

So typescript has something called type discrimination:

https://www.typescriptlang.org/play#example/discriminate-types

however, is something like this possible?

type A = {id: "a", value: boolean};
type B = {id: "b", value: string};

type AB = A | B;

const Configs = {
  a: {
    renderer: (data: A)=>{
      // ...
    }
  },
  b: {
    renderer: (data: B)=>{
      // ...
    }
  }
} as const;

const renderElement = (element: AB)=>{
  const config = Configs[element.id];

  const renderer = config.renderer;
  return renderer(element); 
}

Currently my last line fails with this message. I more or less know what it means but I'm not sure how to fix it.

Argument of type 'AB' is not assignable to parameter of type 'never'.
  The intersection 'A & B' was reduced to 'never' because property 'id' has conflicting types in some constituents.
    Type 'A' is not assignable to type 'never'.(2345)

Longer example It only shows the idea that I'm working on (I didn't event check if it works, recursive typing is prob wrong too, I didn't learn it yet).

type HeaderProps = { text: string, size: number, color: string};
type ImageProps = { src: string, width: number, height: string};
type GroupProps = { numberOfColumns: number, children: Item[], height: string};

type Item = {
  kind: string,
  data: HeaderProps | ImageProps | GroupProps
}

const itemsConfig = {
  header: {
    onRender: (props: HeaderProps)=>{
      
    }
    // + some other handlers...
  },
  image: {
    onRender: (props: ImageProps)=>{
      
    }
    // + some other handlers...
  },
  group: {
    onRender: (props: GroupProps)=>{

    }
    // + some other handlers...
  }
} as const;

const exampleItemsPartA = [
  {
    kind: 'header',
    data: {
      text: 'Hello world',
      size: 16,
    }
  },
  {
    kind: 'header',
    data: {
      text: 'Hello world smaller',
      size: 13,
      color: 'red'
    }
  },
  {
    kind: 'image',
    data: {
      src: 'puppy.png',
      width: 100,
      height: 100,
    }
  }
];

const exampleItems = [
  ...exampleItemsPartA,
  {
    kind: 'group',
    data: exampleItemsPartA
  }
]

const renderItems = (items: Item[])=>{
  const result = [];

  for(const item of items){
    const {onRender} = itemsConfig[item.kind];
    result.push(onRender(item.data))
  }

  return result;
}
7
  • 3
    Okay, this is the first SO question where I'd have pointed someone to ms/TS#30581 and just shrugged, but yesterday ms/TS#47109 was merged to fix it. If I follow the recommendations there, it converts your code to this. Does that work for you? If so I can write up an answer explaining how it works. If not then if you could elaborate on why I'd appreciate it. This is the first real-world test for that "distributive object type" solution. 😅 Commented Dec 15, 2021 at 20:21
  • "Does that work for you?" I'm not sure to be honest 😄 I just started learning TS, so I can't tell what is going on in that code, thus I don't know how flexible it is. Maybe I'll explain what I would like to use it for. Imagine that these types A and B describe a state of some component. There could be a lot of these types, and all of them could use completely different set of props. You could provide an array of these configs (various types), and they would be rendered in a loop. Commented Dec 15, 2021 at 20:53
  • Only id would be required. Commented Dec 15, 2021 at 20:55
  • 1
    I think that would be supported but I can't be certain without a minimal reproducible example. If you can't map an answer from your example to your intended use case, then maybe your example should be a little more fleshed out? Anyway I am happy to write up the answer explaining what is going on with that solution, and you don't have to accept it if you can't understand it. Commented Dec 15, 2021 at 20:58
  • 1
    So does this work for you? I had to change some of your things to make it compile (it helps when your minimal reproducible example is something you test first so that people don't have to fix typos before they can answer) but it's the same general idea. Commented Dec 15, 2021 at 21:32

1 Answer 1

5

For both your original and extended examples, you are trying to represent something like a "correlated union type" as discussed in microsoft/TypeScript#30581.

For example, inside renderElement, the renderer value is a union of functions, while the element value is a union of function arguments. But the compiler won't let you call renderer(element) because it doesn't realize that those two unions are correlated to each other. It has forgotten that if element is of type A then renderer will accept a value of type A. And since it has forgotten the correlation, it sees any call of renderer() as calling a union of functions. The only thing the compiler will accept as an argument is something which would be safe for every function type in the union... something which is both an A and a B, or the intersection A & B, which reduces to never because it's not possible for something to be both an A and a B:

const renderElement = (element: AB) => {
  const config = Configs[element.id];     
  const renderer = config.renderer;
  /* const renderer: ((data: A) => void) | ((data: B) => void) */
  return renderer(element); // error, element is not never (A & B)
}

Anyway, in both cases, the easiest thing to do is to use a type assertion to tell the compiler not to worry about its inability to verify type safety:

  return (renderer as (data: AB) => void)(element); // okay

Here you're telling the compiler that renderer will actually accept A or B, whatever the caller wants to pass in. This is a lie, but it's harmless because you know that element will turn out to be the type that renderer really wants.


Until very recently that would be the end of it. But microsoft/TypeScript#47109 was implemented to provide a way to get type-safe correlated unions. It was just merged into the main branch of the TypeScript code base, so as of now it looks like it will make it into the TypeScript 4.6 release. We can use nightly typescript@next builds to preview it.

Here's how we'd rewrite your original example code to use the fix. First, we write an object type which represents a mapping between the discriminant of A and B and their corresponding data types:

type TypeMap = { a: boolean, b: string };

Then we can define A, B, and AB in terms of TypeMap:

type AB<K extends keyof TypeMap = keyof TypeMap> = 
  { [P in K]: { id: P, value: TypeMap[P] } }[K];

This is what is being called "a distributive object type". Essentially we are taking K, a generic type parameter constrained to the discriminant values, splitting it up into its union members P, and distributing the operation {id: P, value: TypeMap[P]} over that union.

Let's make sure that works:

type A = AB<"a">; // type A = { id: "a"; value: boolean; }
type B = AB<"b"> // type B = { id: "b"; value: string; }
type ABItself = AB // { id: "a"; value: boolean; } | { id: "b"; value: string; }

(Note that when we write AB without a type parameter, it uses the default of keyof TypeMap which is just the union "a" | "b".)

Now, for configs, we need to annotate it as being of a similarly mapped type which turns TypeMap into a version where each property K has a renderer property that is a function accepting AB<K>:

const configs: { [K in keyof TypeMap]: { renderer: (data: AB<K>) => void } } = {
  a: { renderer: (data: A) => { } },
  b: { renderer: (data: B) => { } }
};

This annotation is crucial. Now the compiler can detect that AB<K> and configs are related to each other. If you make renderElement a generic function in K, then the call succeeds because a function accepting AB<K> will accept a value of type AB<K>:

const renderElement = <K extends keyof TypeMap>(element: AB<K>) => {
  const config = configs[element.id];
  const renderer = config.renderer;
  return renderer(element); // okay
}

Now there's no error. And you should be able to call renderElement and have the compiler infer K based on the value you pass in:

renderElement({ id: "a", value: true });
// const renderElement: <"a">(element: { id: "a"; value: boolean; }) => void
renderElement({ id: "b", value: "okay" });
// const renderElement: <"b">(element: { id: "b"; value: string; }) => void

So that's the gist of it. For your extended example you can write your types like

type ItemMap = { header: HeaderProps, image: ImageProps, group: GroupProps };
type Item<K extends keyof ItemMap = keyof ItemMap> = { [P in K]: { kind: P, data: ItemMap[P] } }[K]
type Whatever = { a: any };
const itemsConfig: { [K in keyof ItemMap]: { onRender: (props: ItemMap[K]) => Whatever } } = {
  header: { onRender: (props: HeaderProps) => { return { a: props } } },
  image: { onRender: (props: ImageProps) => { return { a: props } } },
  group: { onRender: (props: GroupProps) => { return { a: props } } }
};

And your renderItems() function also needs a slight refactoring so that each item can be passed to a generic callback:

const renderItems = (items: Item[]) => {
  const result: Whatever[] = [];
  items.forEach(<K extends keyof ItemMap>(item: Item<K>) => {
    const { onRender } = itemsConfig[item.kind];
    result.push(onRender(item.data))
  })
  return result;
}

Playground link to code

Sign up to request clarification or add additional context in comments.

2 Comments

I had so many problems without corelated unions. Nice to have them.
@captain-yossarian It's definitely good to have them but I'm a bit worried that it's too complicated for people to get right. I suppose the TS4.6 release notes will explain how to do it and then I can point to that.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.