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