The Union Type Multiverse
Something I love about JavaScript is its flexibility. It's hard to find a language that feels more productive for quick prototyping. JavaScript is extremely permissive. If we want to pass a new type of value to an existing function, share a callback between several scopes or mutate a shared state, nothing gets in our way. This is both a strength and a weakness because the more complex our code gets and the harder it is to understand it. This is where a type system comes in handy!
Designing a type system sufficiently powerful to capture the dynamic nature of JavaScript was surely no easy task. TypeScript does an amazing job at embracing even the trickiest JavaScript patterns we came up with, and I would argue this is largely due to its support for union types.
Union types are awesome because they let us perfectly model the finite set of possible states our applications can be in. Without them, our types would be so imprecise that they would hardly be of any value.
Let's say we are building an application that fetches data. We could represent its state using this type:
export type DataState = {
status: string;
data?: number;
error?: Error;
};
But this wouldn't help very much. This type doesn't tell us which values the status property can take, or which combinations of properties are permitted. Can you have an error property if status
is equal to "success"? Can you have some data
and an error
at the same time? We just don't know!
import { DataState } from "~/data-state";
let state: DataState = {
status: "success",
error: new Error("This is fine. π₯πΆβοΈπ₯"), // This type-checks.
};
import { DataState } from "~/data-state";
let state: DataState = {
status: "success",
error: new Error("This is fine. π₯πΆβοΈπ₯"), // This type-checks.
};
With a union type, it's a different story:
export type DataState =
| { status: "loading" }
| { status: "success"; data: number }
| { status: "error"; error: Error };
This type lists the precise set of cases our code needs to handle and rules out combinations of properties that do not make sense:
import { DataState } from "~/data-state-v2";
// Our type doesn't let us create invalid states anymore!
let state2: DataState = {
status: "success",
error: new Error("ooooops"), // β does NOT type-check!
};
import { DataState } from "~/data-state-v2";
// Our type doesn't let us create invalid states anymore!
let state2: DataState = {
status: "success",
error: new Error("ooooops"), // β does NOT type-check!
};
This example is deliberately simple but illustrates perfectly why union types are so common in our day-to-day TypeScript code.
Looking at the type system from the perspective of learning a new programming language has been super fruitful so far. Types and values have a lot in common, but if I had to pick a single feature that sets the language of types apart, it would be union types. There are no good JavaScript analogies to fully describe their behavior. Yet, if we want to build useful type-level algorithms, it's crucial to have a deep understanding of the way they work.
By the end of this chapter, you'll know how to transform and filter union types, but more importantly, you'll have an accurate mental model of their behavior. We will see what happens in non-trivial cases such as functions and methods with advanced type signatures and understand why unions behave this way.
Let's get started!
What do we know about Union Types?
In Types Are Just Data, we discovered that types were really sets of values, and that union types were data structures joining several sets together to form larger sets.
Enroll in Type-Level TypeScript!
Get access to all chapters of the Type-Level TypeScript course and join a community of 1600+ students!
You will find everything you need to become a TypeScript Expert β 11 chapters of in-depth, unique content, and more than 70 fun challenges to practice your new skills.
Full access to all 11 chapters
70 type challenges with explanations
Lifetime access to all course materials
Exclusive discord community
Loading...