Discriminated / Tagged Union Types

Typescript Discriminated Union

Typescript Discriminated Union

I learnt about Discriminated Union Types for the first time 2 years back from Typescript in 50 Lessons book by Stefan Baumgartner. The example from the book:

// common properties for an event entity
type TechEventBase = {
  title: string,
  description: string
  date: Date,
  capacity: number,
  rsvp: number,
}

type Talk = {
  title: string,
  abstract: string,
  speaker: string
}

type Conference = TechEventBase & {
  location: string,
  price: number,
  talks: Talk[],
  kind: 'conference'
}

type Meetup = TechEventBase & {
  location: string,
  price: string,
  talks: Talk[],
  kind: 'meetup'
}

type Webinar = TechEventBase & {
  url: string,
  price?: number,
  talks: Talk,
  kind: 'webinar'
}

type TechEvent = Conference | Webinar | Meetup

function getEventTeaser(event: TechEvent) {
  switch(event.kind) {
    case 'conference':
    // We now know that I'm in type Conference
    return `${event.title} (Conference), ` +
    // Suddenly I don't have to check for price as
    // TypeScript knows it will be there
    `priced at ${event.price} USD`
    case 'meetup':
    // We now know that we're in type Meetup
    return `${event.title} (Meetup), ` +
    // Suddenly we can say for sure that this
    // event will have a location, because the
    // type tells us
    `hosted at ${event.location}`
    case 'webinar':
    // We now know that we're in type Webinar
    return '${event.title} (Webinar), ' +
    // Suddenly we can say for sure that there will
    // be a URL
    `available online at ${event.url}`
    default:
    throw new Error('Not sure what to do with that!')
  }      
}

Typescript playground link for above code.

Discriminated union types provide…no surprise…discrimination with the help of a common identifier, using which Typescript is able to identify (discriminate/narrow down) between different kinds of types, just like we can identify rock from metal music.

🤘🏼🎸

In the case above, that common identifier is the property kind on all different types of TechEvent. For your eyes only:

type Conference = TechEventBase & {
  location: string,
  price: number,
  talks: Talk[],
  kind: 'conference'
}

type Meetup = TechEventBase & {
  location: string,
  price: string,
  talks: Talk[],
  kind: 'meetup'
}

type Webinar = TechEventBase & {
  url: string,
  price?: number,
  talks: Talk,
  kind: 'webinar'
}

Once TypeScript checks that an event has a particular kind property, TypeScript can narrow down the TechEvent type to be one of those union types (see getEventTeaser above).

If you don't have a common identifier property like kind, and instead try to do this:

type OtherProperties = {
  location?: string,
  url?: string,
  price?: number | string,
  talks: Talk[] | Talk,
  kind: 'conference' | 'meetup' | 'webinar'
}

type TechEvent = OtherProperties & TechEventBase

There's no necessary knowledge about which properties, the event of kind conference, or meetup, or webinar, definitely has and has not. Every kind of event could have any kind of property, which is not nice. Because we know that url property only makes sense for webinar kind of event, but this type doesn't help enforce it in any way. Neither does it let us narrow types down when we provide switch or if checks on TechEvent typed value.

Discriminated Union type, with a common property across a union of types, helps Typescript to narrow types down, when you check for one particular kind of that property.

But what if you want to make associations between two properties? Suppose say, I have two properties on a type, property isPending and pendingText. I want either both of them to be required or none of them at all. I'm fairly intermediate at TypeScript, and in no way can I pull off what xstate and trpc pull off under the hood, but this is what I found to be one of the ways. And strangely(or not?), it's a form of telling TypeScript a way of discriminating. See this:

type PendingProps = {
  isPending: boolean;
  pendingText: string;
};

type Props = {
  otherProp: string
} 

type Options = Props &
  (
    | (Partial<PendingProps> & { isPending?: never; pendingText?: never })
    | ({ isPending: boolean } & Required<Pick<PendingProps, 'pendingText'>>)
    | ({ pendingText: string } & Required<Pick<PendingProps, 'isPending'>>)
  );

function foo(options: Options) {
  console.info(options)
}

// works
foo({
  otherProp: 'whatever'
})

// works
foo({
  otherProp: 'whatver',
  isPending: true,
  pendingText: 'Loading…'
})

// errors
foo({
  otherProp: 'whatver',
  isPending: false
})

// errors
foo({
  otherProp: 'whatver',
  pendingText: 'Loading…'
})

Typescript Playground link for the above code

Let's break that down.

ONE: Partial<PendingProps> & { isPending?: never; pendingText?: never }.
This means that if you don't provide any of the isPending or pendingText props, both of them will be treated as optional, and you won't get any TypeScript error. When you intersect Partial<PendingProps> with { isPending?: never; pendingText?: never }, it effectively removes the possibility of having isPending or pendingText as optional properties. It kinda makes sense when you read it:

Because if, say, we provide isPending and miss pendingText, TypeScript matches the type with Partial<PendingProps> (since pendingText is missing). But it also has to match the intersection.

Quick Recap: We read Intersection Types (& operator) as and. We combine the properties from one type A with that of another type B, much like extending classes. The result is a new type with the properties of type A and type B.

If Partial<PendingProps> is matched, and it intersects with { isPending?: never; pendingText?: never }, we're basically saying

Nah, uuh! If you give partial, I don't allow you to use either of them, they are never!

TWO: ({ isPending: boolean } & Required<Pick<PendingProps, 'pendingText'>>)
This means that if you provide the isPending prop, the pendingText prop becomes required. The third part says the vice versa.

Recap 🔗

  1. Make partial properties impossible
  2. If one is given, make the other one mandatory.

As and when I find other examples of discriminating in TypeScript, I'll keep updating here. If you learnt something new, or liked this, share it with others 💃🏻