Foundations Updated 2026-09 View as Markdown

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:

typescript
type Id = string | number;
type Status = "pending" | "shipped" | "cancelled";

let id: Id = "abc";
id = 42;          // also fine
id = true;        // error

Those 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.

typescript
function setStatus(s: Status) {}
setStatus("shipped");     // fine
setStatus("shiped");      // error, with a spelling suggestion

Narrowing#

You cannot use a union until you know which member you have:

typescript
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:

typescript
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#

typescript
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.

typescript
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:

typescript
// 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:

typescript
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:

typescript
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#

typescript
// 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 here

Common 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.

Unsubscribe in one click. We never sell the list. Or just take the AGENTS.md now — no email needed.