Union Types and Narrowing
The central idea of TypeScript: a value that could be one of several types, and how the compiler follows your checks to work out which one it is on each branch.
The central idea of TypeScript: a value that could be one of several types, and how the compiler follows your checks to work out which one it is on each branch.
An upload can be in one of four states. Waiting, in progress, finished, failed. A finished upload has a URL; a failed one has an error message; the other two have neither. So the type ends up with four optional fields, and every function that touches it checks whether the URL is there, and one of them forgets.
There is a better way to describe "one of several possibilities", and it is the idea this language is built around. By the end of this lesson you will write unions, understand how the checker follows your checks, and be able to make impossible states impossible to write down.
let id: string | number;
id = "abc-123"; // fine
id = 42; // fine
id = true; // errorA union is written with | and means the value is one of
those types. You have already used one — string | null in the
absence lesson — and it generalises to anything.
The rule that makes unions useful is a restriction: you may only do what is valid for every member.
function describe(id: string | number): string {
return id.toUpperCase();
// ~~~~~~~~~~~
// Property 'toUpperCase' does not exist on type 'number'.
}That is not an obstacle. It is the checker pointing out that this code is wrong for half the values it will receive.
To use what is specific to one member, prove which one you have:
function describe(id: string | number): string {
if (typeof id === "string") {
return id.toUpperCase(); // here, id is a string
}
return id.toFixed(0); // here, it can only be a number
}Inside the if, the type is string. After it, the checker has
eliminated that possibility and knows only number remains.
That is narrowing, and it happens automatically from checks
you would write anyway.
The checks it understands:
typeof value === "string" // string, number, boolean, function...
value === null // and !==
value == null // null or undefined, together
Array.isArray(value) // an array
"size" in photo // does this property exist
value instanceof Error // built from this classAnd truthiness, with the caveat from the conditions lesson:
if (name) { ... } // narrows out null, undefined AND ""That last one is why == null is often the better check — it
narrows absence without also excluding an empty string that was
deliberate.
The values lesson noted that const state = "pending" has type
"pending" rather than string. A union of those is how you
describe a fixed set of allowed values:
type Status = "pending" | "uploading" | "done" | "failed";
function setStatus(status: Status): void { ... }
setStatus("done"); // fine
setStatus("Done"); // error - not one of the fourCompare with status: string, which accepts every piece of text
in existence and catches nothing. This is one of the highest
value-per-character features in the language: four words, and an
entire class of typo becomes impossible.
It also gives you autocomplete. Type setStatus(" and the
editor offers the four options, because it knows them.
Now the pattern that solves the opening problem.
Bad — one shape with optional fields for every state.
type Upload = {
status: string;
url?: string;
error?: string;
progress?: number;
};
function show(upload: Upload): string {
if (upload.status === "done") {
return `Ready: ${upload.url.toUpperCase()}`;
// ~~~~~~~~~~ 'upload.url' is possibly 'undefined'
}
...
}Good — one shape per state, joined by a union.
type Upload =
| { status: "pending" }
| { status: "uploading"; progress: number }
| { status: "done"; url: string }
| { status: "failed"; error: string };
function show(upload: Upload): string {
switch (upload.status) {
case "pending":
return "Waiting";
case "uploading":
return `${upload.progress}%`;
case "done":
return `Ready: ${upload.url.toUpperCase()}`; // url is a string
case "failed":
return `Failed: ${upload.error}`;
}
}Every field is possibly undefined.
url is string | undefined everywhere, including the
branch where it is guaranteed — so you add a check that
cannot fail, or write ! and remove the protection.
And { status: "done" } with no URL is a perfectly valid
Upload. The type permits a state your program cannot
handle.
Each branch sees only what exists.
Several object types sharing one property whose literal type differs. Checking it narrows to exactly one member.
A done without a URL will not compile, so there is no
illegal state left to handle.
The second version is a discriminated union, and it is the shape most worth learning in this whole course.
The rule underneath is worth stating: make illegal states impossible to write down. Every optional field is a combination somebody has to handle; a union of exact shapes has no illegal combinations to handle.
The best part arrives when the set changes. Add a state:
type Upload =
| { status: "pending" }
| { status: "cancelled" } // new
| ...Make the function return a type and TypeScript reports every
switch that no longer covers everything:
function show(upload: Upload): string {
switch (upload.status) {
case "pending": return "Waiting";
// ...cancelled not handled
}
// Function lacks ending return statement.
}To make it explicit and get a better message, use never:
default: {
const unhandled: never = upload;
throw new Error(`Unhandled status: ${JSON.stringify(unhandled)}`);
}Every case is handled
By the default branch, the checker has eliminated all four
members, so upload has type never — nothing is left.
never is assignable to never
So const unhandled: never = upload compiles, and the
throw is unreachable dead code that never runs.
Miss one, and it stops compiling
The remaining member is not assignable to never, and the
error names exactly which state you forgot.
That turns adding a state from "find every place that switches on this" into "compile, and fix what it lists".
The counterpart to | is &, which combines rather than
choosing:
type Timestamps = { createdAt: string; updatedAt: string };
type Photo = { name: string; size: number };
type StoredPhoto = Photo & Timestamps;A StoredPhoto has all four properties. This is an
intersection, and it is useful for adding a common set of
fields to several types.
Read them as: | is "or", & is "and". The symbols look
backwards to some people because a union has more possible
values while requiring fewer guarantees — but "or" and "and"
is the reliable reading.
One limitation worth knowing before it confuses you:
function show(upload: Upload): string {
if (upload.status === "done") {
return getUrl();
function getUrl(): string {
return upload.url; // error - not narrowed here
}
}
}Narrowing applies to the flow the checker can follow. Inside a nested function it cannot know when that will run, so the narrowed type does not apply.
The same happens with a value that could be changed between the
check and the use. Assigning the narrowed value to a const
first is the usual fix:
if (upload.status === "done") {
const url = upload.url; // string, captured here
...
}UNIONS
string | number one of these
you may only do what is valid for EVERY member
NARROWING - prove which one you have
typeof x === "string" string number boolean function symbol
x === null x == null both null and undefined
Array.isArray(x)
"size" in photo does the property exist
x instanceof Error
if (x) also narrows out "" and 0 - careful
LITERAL UNIONS
type Status = "pending" | "done" | "failed";
four words, and every typo becomes an error
autocomplete at the call site, for free
compare with `string`, which accepts everything
DISCRIMINATED UNIONS
type Upload =
| { status: "done"; url: string }
| { status: "failed"; error: string };
switch on the shared literal property -> each branch sees only
the fields that exist in that state
the alternative - one shape with optional fields - makes every
field possibly-undefined even where it is guaranteed, AND lets
you construct states your program cannot handle
make illegal states impossible to write down
EXHAUSTIVENESS
default: { const x: never = value; throw ... }
every case handled -> value is never -> compiles
one missed -> error naming exactly what you forgot
adding a state becomes: compile, fix the list
INTERSECTIONS
A & B has everything from both
| is "or", & is "and"
LIMITS
narrowing does not reach into a nested function
capture the narrowed value in a const firstYou now have the central idea of the language: describe what is actually possible, and let the checker follow your checks to work out which case you are in. Discriminated unions plus exhaustiveness turn "remember to update every switch" into a compile error, which is the difference between a rule and a guarantee.
Next is Interfaces Versus Type Aliases, which addresses the question every reader has by now: there is a second way to describe an object shape, and a rule for choosing that will not start an argument in review.
Before you move on, take something in your code with a status
field and two or three optional properties, and rewrite it as a
discriminated union. Then add a fourth state and see how many
errors appear. Each one is a place that would have been a bug.