I know that much has already been said about typing discriminated unions, but I could not find a solution for my particular case yet. Say I have the following code:
type A = {label: 'a', options: {x: number}; text: string}; // label is intended to act as tag
type B = {label: 'b', options: {y: string}; text: string};
type C = A | B;
type D<T extends C> = {
label: T['label'];
displayOptions: T['options'];
complexValue: T extends B ? T['options']['y'] : never;
};
function f<U extends D<C>>(u: U) {
if (u.label === 'a') {
u.displayOptions // is inferred as {x: number} | {y: string} instead of just {x: number}
}
}
At the commented place, I would expect the type of u.displayOptions
to be inferred as {x: number}
because label
should act as a "tag" as suggested here for a similar problem. But this doesn't work; the type is still {x: number} | {y: string}
.
I suspect that is because in the definition of D
, I only indirectly use T['label']
and T['options']
, since it does work if I would use a property type: T
in D
instead, and then if (t.type.label === 'a')
.
However, it seems I cannot do that for the following reasons:
T
(or C
) in D
(such as text
).displayOptions
instead of options
).T
, like complexValue
.Is there any (preferrably simple) solution that can achieve all of this?
With generics, there is no safe way for the compiler to narrow the type, which is described in ms/TS#33014 since we don't know the passed type exactly. Example:
function f<T extends 'a' | 'b'>(x: T) {
if (x === 'a') {
x; // T extends "a" | "b"
}
}
In reality, generics are not what you need in your use case. What you actually need are distributive conditional types. Unions are distributed when they are checked against some condition (extends something
) and to make sure that every member of the union passes the check we need to find some condition that will be always true. For instance T extends any
or even T extends T
. By distributing, we will re-create the union again with modified/added fields. To remove the unnecessary fields we will use the built-in Omit utility type. Since we are adjusting the members of the union separately, we will also adjust the way an extra field is added:
type D<T extends C = C> = T extends T
? Omit<T, 'options' | 'text'> & {
displayOptions: T['options'];
} & (T extends B ? { complexValue: T['options']['y'] } : {})
: never;
Testing:
// (Omit<A, "options" | "text"> & {
// displayOptions: {
// x: number;
// };
// }) | (Omit<B, "options" | "text"> & {
// displayOptions: {
// y: string;
// };
// } & {
// complexValue: string;
// })
type Result = D;
Even though the type looks correct it is really hard to read. To fix it we can use Prettify utility type defined in the type-samurai package:
type Prettify<T> = T extends infer R
? {
[K in keyof R]: R[K];
}
: never;
Basically, it creates a copy of the passed type and by using mapped types remaps it, which forces the compiler to remove the unwanted intersection and aliases:
// {
// label: 'a';
// displayOptions: {
// x: number;
// };
// }
// | {
// label: 'b';
// displayOptions: {
// y: string;
// };
// complexValue: string;
// };
type Result = Prettify<D>;
Now, you can accept a parameter of type Result
and everything should work as expected. Final testing:
function f(u: Result) {
if (u.label === 'a') {
u.displayOptions; // {x: number}
}
}