Christian Gill

Generic discriminated union narrowing

DALL·E 3 prompt: Sharp digital drawing symbolizing the concept of 'Generic discriminated union narrowing' in TypeScript. The left side showcases various geometric shapes, each representing a different data type or union. As we move to the right, a funnel structure appears, narrowing down and filtering these shapes into a single, refined form, illustrating the process of narrowing. This transformation is accentuated by interconnected lines and nodes, highlighting the connections and relationships. Soft pastel colors provide a harmonious backdrop, underscoring the elegance and precision of TypeScript's capabilities.

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 of Union, it should extend the base type of our tag, in this case string
  • Union is our tagged union type, it has to be an object with a tag property of type Tag
  • T is the specific tag we want to narrow down with, and thus it should extend the Tag type
  • And U is the specific member of Union we want to narrow down to, it should extend the Union and its tag should be T, 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!

inferred creditcard type

And this is how TypeScript fills in the holes of the generics.

inferred type arguments

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}).

inferred returned type of getter

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 to Union.

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

slaps roof of car meme

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

better inferred return type

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.