Union Types and Narrowing
A union says a value is one of several things. Narrowing is how you prove which one — and it is the most distinctively TypeScript skill there is.
A union type says a value is one of several possibilities:
type Id = string | number;
type Status = "pending" | "shipped" | "cancelled";
let id: Id = "abc";
id = 42; // also fine
id = true; // errorThose quoted strings are literal types — a type whose only value is that exact string. A union of them is how you model a fixed set of options, and it is better than an enum for most purposes because it emits no runtime code.
function setStatus(s: Status) {}
setStatus("shipped"); // fine
setStatus("shiped"); // error, with a spelling suggestionNarrowing#
You cannot use a union until you know which member you have:
function format(id: string | number): string {
return id.toUpperCase(); // error — number has no toUpperCase
}Narrowing is proving to the compiler which member it is. Ordinary JavaScript checks do it:
function format(id: string | number): string {
if (typeof id === "string") {
return id.toUpperCase(); // here, id is string
}
return id.toFixed(2); // here, id is number
}That is the part people find magical at first: the compiler follows your control flow. Inside the if, id genuinely has type string.
The narrowing tools#
typeof x === "string" // primitives
Array.isArray(x) // arrays
x instanceof Error // classes
"email" in user // presence of a property
x === null / x !== undefined // equality
if (x) { } // truthiness — careful with 0 and ""Discriminated unions#
The pattern that makes unions genuinely powerful. Give each member a shared literal property, and narrowing on that property picks the whole shape.
type Result =
| { status: "loading" }
| { status: "success"; data: User }
| { status: "error"; error: Error };
function render(r: Result): string {
switch (r.status) {
case "loading": return "Loading…";
case "success": return r.data.email; // data exists only here
case "error": return r.error.message; // error exists only here
}
}Compare that with the shape generated code usually produces:
// 16 possible states, most of them nonsense
type Result = {
status: "loading" | "success" | "error";
data?: User;
error?: Error;
};With the second version you must check r.data everywhere and nothing stops a "success" with no data. With the first, the invalid states cannot be constructed. That is the single most valuable modelling habit in TypeScript.
Exhaustiveness checking#
Add a default branch that assigns to never, and adding a new union member becomes a compile error everywhere it needs handling:
function assertNever(x: never): never {
throw new Error(`Unhandled: ${JSON.stringify(x)}`);
}
function render(r: Result): string {
switch (r.status) {
case "loading": return "Loading…";
case "success": return r.data.email;
case "error": return r.error.message;
default: return assertNever(r);
}
}Now add | { status: "cancelled" } to Result. The default branch fails to compile, because r is no longer never there — and it fails in every switch that needs updating. The compiler has just found all your work for you.
This is the pattern that makes a large TypeScript codebase safe to change.
Type predicates#
When a check is too complex for the built-in narrowing, write a function that returns a type predicate:
function isUser(value: unknown): value is User {
return (
typeof value === "object" && value !== null &&
"id" in value && "email" in value
);
}
const data: unknown = JSON.parse(raw);
if (isUser(data)) {
data.email; // narrowed to User
}value is User tells the compiler that a true return means the narrowing holds.
Exercise#
// Model the outcome of a payment as a discriminated union:
// - "approved" with a transactionId (string)
// - "declined" with a reason (string)
// - "pending" with a retryAfter (number of seconds)
// Write `describe(result)` returning a human sentence for each,
// with an assertNever default branch.
// write your code hereCommon questions#
Union of literals or an enum?#
A union of string literals, in most cases. It emits no runtime JavaScript, it narrows naturally in a switch, and the values are just strings so they serialise cleanly. Enums generate real code and are not erasable syntax, which matters if you use a runtime type-stripper.
Why does my narrowing stop working inside a callback?#
TypeScript cannot guarantee a narrowing still holds inside a function that may run later, because the variable could have been reassigned in between. Assign the narrowed value to a const first and use that inside the callback.
When should I write a type predicate rather than parse?#
Predicates suit checks over data you already own — distinguishing two internal shapes. For anything arriving from outside your program, parse with a schema instead: a predicate asserts a belief, whereas a parse actually verifies it and gives you the type as a by-product.
Get the TypeScript agent pack
A battle-tested AGENTS.md, the review checklist, and the failure-mode cheat sheet for TypeScript. One email, then occasional updates when the tooling shifts. No course pitch.
AGENTS.md now — no email needed.