My goal is to use an object to render a React component based on a data type property. I'm trying to find a way to avoid switch
cases when I need to narrow a discriminated union type in TypeScript. The regular approach is to write an if-else or a switch and TS narrows the value automatically:
type AView = {type: 'A', name: string}
type BView = {type: 'B', count: number}
type View = AView | BView
const prints = {
'A': (view: AView) => `this is A: ${view.name}`,
'B': (view: BView) => `this is B. Count: ${view.count}`,
} as const
const outputWorks = (view: View): string => {
switch (view.type) {
case 'A':
// or prints['A'](view)
return prints[view.type](view)
case 'B':
// or prints['B'](view)
return prints[view.type](view)
}
}
outputWorks({type: 'A', name: 'John'})
I'm wondering whether it is possible to avoid the switch and convince TS that the object can narrow the data:
type AView = {type: 'A', name: string}
type BView = {type: 'B', count: number}
type View = AView | BView
const prints = {
'A': (view: AView) => `this is A: ${view.name}`,
'B': (view: BView) => `this is B. Count: ${view.count}`,
} as const
const outputFail = (view: View): string => prints[view.type](view)
outputFail({type: 'A', name: 'John'})
With this approach I get the error The intersection 'AView & BView' was reduced to 'never' because property 'type' has conflicting types in some constituents.
because the TS does not narrow the type.
You can see this code in the TS Playground and play with it.
I'm investigating this issue for a week already and I'm about to give up. I've seen several similar threads, but couldn't make this code work.
The underlying issue is the lack of direct support for correlated unions as described in microsoft/TypeScript#30581. Inside outputFail
, the type of prints[view.type]
is the union type ((view: AView) => string) | ((view: BView) => string)
, and the type of view
is the union type AView | BView
. If all you knew is that you had a function f
of the same type as prints[view.type]
, and a value v
of the same type as view
, then it wouldn't be safe to call f(v)
, because what if v
is an AView
but f
is a (view: BView) => string
? Given the types involved, this looks like a possibility, and the compiler complains.
Of course it's not really a possibility. The union type of prints[view.type]
and the union type of view
are correlated in such a way that they are always appropriate for each other. But the compiler has no way to represent such correlated unions.
The suggested way to approach these things is described in microsoft/TypeScript#47109. The idea is to replace unions with generics, and refactor your types in such a way as to make it obvious to the compiler that the function and the argument are compatible. That is, f
would be of some generic type (arg: Arg<K>) => void
, and v
would be of generic type Arg<K>
.
Here's one implementation:
interface BaseView {
A: { name: string };
B: { count: number };
}
type View<K extends keyof BaseView = keyof BaseView> = {
[P in K]: { type: P } & BaseView[P]
}[K];
Here we've redefined View
to be a generic distributive object type built from a "base" mapping. The type View
with no type argument is equivalent to your original version, while you can recover your AView
and BView
types by specifying the right key:
type AView = View<"A">;
type BView = View<"B">;
const printA = (view: AView) => {
return `this is A: ${view.name}`
}
const printB = (view: BView) => {
return `this is B. Count: ${view.count}`
}
Now, when we declare prints
, we need to write its type explicitly in terms of a mapped type over the same "base" mapping, so that later when we index into it, we get a generic type instead of a union:
const prints: { [K in keyof BaseView]: (view: View<K>) => string } = {
'A': printA,
'B': printB,
}
And here we go:
const output = <K extends keyof BaseView>(view: View<K>): string => {
const fn = prints[view.type];
return fn(view); // okay
}
If you look, fn
is of a type equivalent to (view: View<K>) => string
, and view
is of type View<K>
, so everything works.