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:
Approaches for type narrowing
How do we go about implementing the <p-inline>getName<p-inline> function?
To do an inline check here, we need to use the <p-inline>in<p-inline> operator. You cannot simply do <p-inline>if<p-inline> (<p-inline>pet.collar<p-inline>) 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
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:
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:
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 <p-inline>microchip<p-inline> property, perhaps to <p-inline>microChip<p-inline>. 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:
Now, our type predicate for <p-inline>is Cat<p-inline>is 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?
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 <p-inline>type<p-inline> or <p-inline>kind<p-inline> 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 <p-inline>kind<p-inline>, and it looks like this:
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.
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:
And we might render this like this:
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:
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:
This is definitely more typing than just setting <p-inline>isLoading<p-inline> 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:
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:
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!
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.