37° 48' 15.7068'' N, 122° 16' 15.9996'' W
cloud-native gis has arrived
37° 48' 15.7068'' N, 122° 16' 15.9996'' W
cloud-native gis has arrived
37° 48' 15.7068'' N, 122° 16' 15.9996'' W
cloud-native gis has arrived
37° 48' 15.7068'' N, 122° 16' 15.9996'' W
cloud-native gis has arrived
37° 48' 15.7068'' N, 122° 16' 15.9996'' W
cloud-native gis has arrived
37° 48' 15.7068'' N, 122° 16' 15.9996'' W
cloud-native gis has arrived
37° 48' 15.7068'' N, 122° 16' 15.9996'' W
cloud-native gis has arrived
37° 48' 15.7068'' N, 122° 16' 15.9996'' W
cloud-native gis has arrived
37° 48' 15.7068'' N, 122° 16' 15.9996'' W
cloud-native gis has arrived
37° 48' 15.7068'' N, 122° 16' 15.9996'' W
cloud-native gis has arrived
Maps
Engineering
Narrowing in TypeScript with type predicates and discriminated unions
Discriminated unions are the natural fit for working with unions in TypeScript. Here's why I advise them over inline checks and type predicates.
Discriminated unions are the natural fit for working with unions in TypeScript. Here's why I advise them over inline checks and type predicates.

It's common in TypeScript to receive an object which could be one of a number of types, and before you operate on it, you need to determine which particular type it is.

One example of such a type union might be a state object representing an async request that could be loading, successful, or failed. Another example could be having a user object which could be an admin user, a normal user, or a logged-out user.

The process of figuring out which type you have from several is called "narrowing" and there are several ways to approach it.

In this article, we're going to cover:

  • When we need type narrowing
  • Type narrowing using inline checks
  • Type narrowing with type predicates
  • Type narrowing with discriminated unions
  • The advantages and disadvantages of these approaches
  • How discriminated unions help maintainability in a real-world scenario

When do we need type narrowing?

We're going to use a toy example of dealing with pets, that could either be cats or dogs. Here's how we might model this, along with where we need type narrowing to determine the pet's name:

type Dog = {
numberOfLegs: number
collar: {
name: string
material: "leather" | "plastic"
}
}
type Cat = {
numberOfLegs: number
microchip: {
id: string
name: string
}
}
type Pet = Dog | Cat
function numberOfLegs(pet: Pet): number {
return pet.numberOfLegs //
this is fine as all pets share a common property
}
function getName(pet: Pet): string {
// If it's a dog, read the collar
// If it's a cat, read the microchip
// ^-- Figuring this out is type narrowing
}

Approaches for type narrowing

How do we go about implementing the getName function?

Inline checks

function getName(pet: Pet): string {
if ("collar" in pet) {
// TypeScript knows that pet is a Dog now
return pet.collar.name
}
// TypeScript knows that it can only be a Cat now
return pet.microchip.name
}

To do an inline check here, we need to use the in operator. You cannot simply do if (pet.collar) because TypeScript won't let you read a property unless it knows it exists.

Advantages of inline checks:

  • Quick to implement
  • Cannot do it wrong – TypeScript will ensure your checks are sufficient to narrow the type
  • No need to change your existing data or types

Disadvantages of inline checks:

  • Slightly messy and noisy as we're mixing specific object shape checks with using the object
  • Not reusable – you'll need to do this every time you want to narrow, and it could get longer
  • Checks can get complex

Type predicates

A type predicate is a specially-defined function that returns a boolean when a specified argument returns true.

For our pets example, it would look like this:

function isCat(pet: Pet): pet is Cat {
return "microchip" in pet
}
function isDog(pet: Pet): pet is Dog {
return "collar" in pet
}
function getName(pet: Pet): string {
if (isDog(pet)) return pet.collar.name
if (isCat(pet)) return pet.microchip.name
}

Advantages of type predicates:

  • No need to change existing data or types
  • Encapsulates the logic of checking the type away from using the type

Disadvantages of type predicates:

  • Requires multiple if/else blocks
  • When we use them, it's not clear that Cat and Dog are disjoint: is the order important here?
  • Our checks can go wrong without us knowing

This last point is dangerous and important, so warrants further explanation on how this approach can fail.

The first way this could fail is that we just make a mistake in our predicate:

function isCat(pet: Pet): pet is Cat {
return "mcirochip" in pet // 🔥 spelling mistake - no cats ever!
}

TypeScript will not warn us about this! Whenever we assert a type like this, we're telling the compiler that we know best and to trust us. In this case, that trust is misplaced!

This could also happen if we spelled it correctly, but then changed the name of the microchip property, perhaps to microChip. Now we have to remember to change things in two places whenever we change our types, and TypeScript isn't going to help us remember!

The second way that type predicates can go wring is even more pernicious!

Let's say we now add another type of pet to our union:

type Hamster = {
microchip: {
id: string
name: string
}
}
type Pet = Dog | Cat | Hamster

Now, our type predicate for is Catis wrong because cats don't know about the existence of hamsters. This is going to cause crashes at some point.

How can we get the benefits of keeping our code clean, modeling our distinct types correctly, and making sure TypeScript helps us with anything that changes?

Discriminated unions

That's a very fancy-sounding term, but what it really means is:

Add a property to each type that says what type it is.

That property is called a discriminant and it's often called type or kind but that is merely a convention. There are cases where other names are more suitable (see the section on async requests, later).

In our pets example we're adding a discriminant called kind, and it looks like this:

type Dog = {
kind: "dog" // 👀
numberOfLegs: number
collar: {
name: string
material: "leather" | "plastic"
}
}
type Cat = {
kind: "cat" // 👀
numberOfLegs: number
microchip: {
id: string
name: string
}
}
type Pet = Dog | Cat
function getName(pet: Pet): string {
switch (pet.kind) {
case "dog": return pet.collar.name
case "cat": return pet.microchip.name
}
}

Advantages of discriminated unions:

  • Our checks become dead simple – "reusability" is as simple as checking a property
  • If we add a new item to the union, we're forced by TypeScript to handle all the cases where we use this type downstream
  • We get autocomplete hints for the discriminant values
  • We are forced to model our domain more carefully, which leads to clearer, more robust programs

Disadvantages of discriminated unions:

  • We need to change our types if we are applying this to existing code

In my opinion, discriminated unions are the natural fit for working with unions in TypeScript, and I strongly advise them over inline checks and type predicates where possible.

Why are discriminated unions so great?

I want to explain a little bit about the final advantage cited there, because it's a core reason why I recommend discriminated unions. It goes beyond just figuring out what type something is, and has positive implications for the rest of your code.

Let's circle back to one of the original real-world examples mentioned in the introduction: modeling an async request.

Starting point

Let's say we are requesting a user profile, and we need to render it in a React component. The types might look like this:

❌ Unclear relationships between properties
type UserProfileRequest = {
isLoading: boolean
data?: UserProfileData
errorMessage?: string
statusCode?: number
}
// NOTE: In reality, we would probably use a generic like Loadable<UserProfileData>
// but that's outside the scope of this article.

And we might render this like this:

❌ A "simple" component which isn't really simple
function UserProfile({request}: {request: MyProfileRequest}) {
// can we have stale data while we're loading? I guess not?
if (request.isLoading) return <Spinner />
// this goes before checking the errors, so I presume we can't have
// data and an error together? Or maybe we just don't care if there's
// an error but we get data?
if (request.data) return <UserProfileCard user={request.data} />
// can we have an errorMessage without a statusCode?
// how would we render that?
if (request.errorMessage && request.statusCode) {
return <ErrorMessage
code={request.statusCode}
message={request.errorMessage} />
}
// will this ever be exercised? is this dead code?
return null
}

Those comments might seem really picky, but they're driving at an important point: we are having to make guesses about the possible combinations of these properties as we're using them.

More specifically: we haven't explicitly written down anywhere what the possible combinations are.

Aside from having to guess about what our possible states are by looking at the rendering of it, we also have a headache if we add the requirement that we can refetch, so we can be loading at the same time as having stale data or a stale error message.

To see why the current way is bad, let's look at an alternative way using discriminated unions.

Discriminated unions to the rescue

Let's start with our types:

✅ Explicit modeling of distinct states
type UserProfileFetching = {
status: "fetching"
}
type UserProfileSuccess = {
status: "success"
data: UserProfileData
statusCode: number
}
type UserProfileError = {
status: "error"
errorMessage: string
statusCode: number
}
type UserProfileRequest = UserProfileFetching |
UserProfileSuccess | UserProfileError

For the first time, we can see explicitly what the possible combination of properties is! This is good.

Now when we render it, we have this:

✅ New states for new requirements
type UserProfileRefetchAfterError = {
status: "refetch-after-error"
errorMessage: string
statusCode: number
}
type UserProfileRefetchAfterSuccess = {
status: "refetch-after-success"
data: UserProfileData
statusCode: number
}

This is definitely more typing than just setting isLoading to true – so why is the discriminated union preferable?

Let's consider what happens with the React component in these two cases.

In the first case, everything seems to work right away. TypeScript doesn't complain, so we must be good, right? Well, technically yes. It compiles, and it won't crash. But, we're not dealing with the cases where we have data and are loading properly. Our component just renders a spinner, and the user disappears, which isn't what we want.

Let's fix it:

❌ The complex component got even more complex
function UserProfile({request}:
{request: MyProfileRequest}) {
// 🤞 let's hope this boolean
combination is right
if (request.isLoading && !request.data &&
!request.errorMessage) {
return <Spinner />
}
if (request.data) {
return (<UserProfileCard
isLoading={request.isLoading}
user={request.data}
/>)
}
// still hope we get both of these here!
if (request.errorMessage && request.statusCode) {
return (<>
<ErrorMessage
code={request.statusCode}
message={request.errorMessage}
/>
{request.isLoading && <Spinner />} //
nested conditions are a bit unpleasant
</>)
}
// I hope we don't get here
return null
}

So apart from the fact that we had to remember to go and change this, we've also got a load of nasty boolean combinations and I'm honestly not sure what I've written above is correct.

Compare this to the discriminated union version:

✅ Our component got longer,
but is no more complex than before
function UserProfile({ request }:
{ request: UserProfileRequest }) {
switch (request.status) {
case "fetching":
return <Spinner />
case "success":
return <UserProfileCard user={request.data} />
case "error":
return (
<ErrorMessage
code={request.statusCode}
message={request.errorMessage}
/>
)
case "refetch-after-error":
return (
<>
<ErrorMessage
code={request.statusCode}
message={request.errorMessage}
/>
<Spinner />
</>
)
case "refetch-after-success":
return <UserProfileCard
isLoading user={request.data} />
}
}

It's more lines, sure, but everything is explicit. TypeScript forced us to make this change, and let us know exactly what data we had available at all times. This is a more robust solution, where we don't have to remember to update things across your codebase–TypeScript acts as our memory!

Overall, despite there being more lines of code, and a little duplication when rendering, I know which I would prefer to maintain!

Conclusion

Discriminated unions are one of at least three ways of figuring out which specific type you have when presented with a union. They require more modeling of your domain up front, and require changing your existing types, but they confer some big advantages over other approaches.

These advantages go beyond distinguishing between types, pushing us towards more robust, maintainable solutions where we offload the job of remembering important details about our codebase to the compiler.

Bio
LinkedIn
More articles