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 🔗
- Make partial properties impossible
- 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 💃🏻
- Next: Old Jems
- Previous: Iterators for Pagination