Generic discriminated union narrowing
Tags: typescript, programming
Published: 2022-08-31 08:33:43

> This post was originally a note on my [Digital Garden 🌱](https://publish.obsidian.md/gillchristian)

When working with discriminated unions, also known as tagged unions, in [TypeScript](https://publish.obsidian.md/gillchristian/Research/TypeScript) one often has to narrow down a value of the union, to the type of one of the members.

```ts
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.

```ts
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](https://www.typescriptlang.org/docs/handbook/2/narrowing.html#using-type-predicates), or guards.

```ts
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.

```ts
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.

```ts
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](https://photos.collectednotes.com/photos/63/7529fd81-1138-4360-a81a-c87d65fbd4bd)

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

![inferred type arguments](https://photos.collectednotes.com/photos/63/ff441eb1-e044-4e22-97b1-2821742c1452)

There's a variation of `isMemberOfType` that I like to use which works as a sort of getter instead of type predicate.

```ts
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.

```ts
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](https://photos.collectednotes.com/photos/63/bf825eb9-a82c-490e-b893-9c835e7b5db7)

In practice this is not a problem, but since we got this far, we can keep going, right?

Enter [Extract<Type, Union>](https://www.typescriptlang.org/docs/handbook/utility-types.html#extracttype-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'`:

```ts
type T0 = Extract<'a' | 'b' | 'c', 'a' | 'f'>
```

The definition is simple:

```ts
// Extract from Type those types that are assignable to Union
type Extract<Type, Union> = Type extends Union ? Type : never
```

![slaps roof of car meme](https://photos.collectednotes.com/photos/63/204e9c1d-c050-433a-a583-be737ff70fe6)

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.


```ts
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](https://photos.collectednotes.com/photos/63/78d2280d-a28a-4be3-9cec-6e4bd264a615)

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](https://tsplay.dev/NBRQbN).