Generic discriminated union narrowing
This post was originally a note on my Digital Garden 🌱
When working with discriminated unions, also known as tagged unions, in TypeScript one often has to narrow down a value of the union, to the type of one of the members.
type Creditcard = {tag: 'Creditcard'; last4: string}
type PayPal = {tag: 'Paypal'; email: string}
type PaymentMethod = Creditcard | PayPal
Given the above type, we would want to either check that a value is of PayPal type to show the user's email or Creditcard type to show the last 4 digits.
This can be do simply but checking the discriminant property, in this case tag
. Since each of the two members of the PaymentMethod
type have different types for the tag
property, TypeScript is able to, well, discriminate between them to narrow down the type. Thus the tagged union name.
declare const getPaymentMethod: () => PaymentMethod
const x = getPaymentMethod()
if (x.tag === 'Paypal') {
console.log("PayPal email:", x.email)
}
if (x.tag === 'Creditcard') {
console.log("Creditcard last 4 digits:", x.last4)
}
To avoid this explicit check of the tag
property you might define type predicates, or guards.
const isPaypal = (x: PaymentMethod): x is PayPal => x.tag === 'Paypal'
const x = getPaymentMethod()
if (isPayPal(x)) {
console.log("PayPal email:", x.email)
}
The problem with such guards is they have to be defined for each member of each discriminated union type in your application. Or do they?
If you are like me you probably wonder how to do this generically for all union types. Could this be even possible with some TypeScript type level black magic?
The short answer is yes.
const isMemberOfType = <
Tag extends string,
Union extends {tag: Tag},
T extends Union['tag'],
U extends Union & {tag: T}
>(
tag: T,
a: Union
): a is U => a.tag === tag
The proper response is, what the f*ck heck hack?
Ok, let me explain.
We need four generic types, in practice the caller will not need to pass these, they are there with the purpose of allowing TypeScript to do its type inference business.
Notice that for all of them we are not allowing any type but instead using extends
to inform TypeScript that those generic types should meet certain conditions.
Tag
is the union of all the tags ofUnion
, it should extend the base type of our tag, in this casestring
Union
is our tagged union type, it has to be an object with atag
property of typeTag
T
is the specific tag we want to narrow down with, and thus it should extend theTag
type- And
U
is the specific member ofUnion
we want to narrow down to, it should extend theUnion
and its tag should beT
, the specific one we want
Tag
and Union
are there to define the union type we want to narrow down from and T
and U
, for the lack laziness of finding better names, are where the magic happens since they are the narrowed down types we expect to get to.
const x = getPaymentMethod()
if (isMemberOfType('Creditcard', x)) {
console.log('Creditcard last 4 digits:', x.last4)
}
if (isMemberOfType('Paypal', x)) {
console.log('PayPal email:', x.email)
}
Et voilà, it works!
And this is how TypeScript fills in the holes of the generics.
There's a variation of isMemberOfType
that I like to use which works as a sort of getter instead of type predicate.
const getMemberOfType = <
Tag extends string,
Union extends {tag: Tag},
T extends Union['tag'],
U extends Union & {tag: T}
>(
tag: T,
a: Union
): U | undefined =>
isMemberOfType<Tag, Union, T, U>(tag, a) ? a : undefined
The usage is similar, but it requires a null check, of course.
const cc = getMemberOfType('Creditcard', getPaymentMethod())
if (cc) {
console.log('Creditcard last 4 digits:', cc.last4)
}
const pp = getMemberOfType('Paypal', getPaymentMethod())
if (pp) {
console.log('PayPal email:', pp.email)
}
There’s a little problem, the inferred type it returns is not so nice, since it's based on our definition of the generic (U extends Union & {tag: T}
).
In practice this is not a problem, but since we got this far, we can keep going, right?
Enter Extract<Type, Union>, one of TypeScript's type utilities:
Constructs a type by extracting from
Type
all union members that are assignable toUnion
.
For example, in the following case T0
will be of type 'a'
:
type T0 = Extract<'a' | 'b' | 'c', 'a' | 'f'>
The definition is simple:
// Extract from Type those types that are assignable to Union
type Extract<Type, Union> = Type extends Union ? Type : never
With our current definition of isMemberOfType
and getMemberOfType
, the returned type extends union: U extends Union & {tag: T}
.
In the case of PayPal it would be PayPal & { tag: 'PayPal' }
. By adding Extract
to the returned type we can get PayPal
instead.
const isMemberOfType = <
Tag extends string,
Union extends {tag: Tag},
T extends Union['tag'],
U extends Union & {tag: T}
>(
tag: T,
a: Union
): a is Extract<Union, U> => a.tag === tag
const getMemberOfType = <
Tag extends string,
Union extends {tag: Tag},
T extends Union['tag'],
U extends Union & {tag: T}
>(
tag: T,
a: Union
): Extract<Union, U> | undefined =>
isMemberOfType<Tag, Union, T, U>(tag, a) ? a : undefined
Much cleaner this way! Now I can sleep in peace …
In conclusion, we went from simple discriminant property check to a monstrosity of generics and type inference that achieves the same thing. Should you use these utilities in production? Of course! Even more so if it will confuse our colleagues. Maybe not, but it was fun to discover some of the cool stuff we can achieve with TypeScript.
Here's the final version of the examples in the TypeScript Playground.