Just added: Algorithms you can run and practice

Try it in Practice →

Updated Jun 22, 2026 Edit on GitHub

Unions, Literals & Narrowing - One of Several Shapes

So far your types have described one thing: a value is a string, or a Product, or a number[]. But real programs are full of values that are one of several possibilities - an API field that's a string or null, a status that's "pending", "shipped", or "cancelled" and nothing else, a shape that's either a circle or a square, each with its own measurements.

TypeScript lets you say "this value is one of these specific possibilities" - and then refuses to let you use it until you've figured out which one you're holding. That second half is the magic. The checker tracks your code branch by branch, and inside an if that proves "this is a string," it knows it's a string and lets you call string methods. This is where TypeScript stops feeling like paperwork and starts feeling genuinely smart.

Union types - a value that's one of several types

📝 Union type - a type written A | B meaning "a value of type A or type B." A string | number variable can hold either, and TypeScript only lets you do things valid for both until you prove which one you've got.

That last clause is the rule that trips people up.

function format(id: string | number) {
  return id.toUpperCase(); // Error
}
Property 'toUpperCase' does not exist on type 'string | number'.
  Property 'toUpperCase' does not exist on type 'number'.

What just happened: id is string | number, so it might genuinely be a number, and numbers have no .toUpperCase(). TypeScript blocks the call because the operation isn't safe for every member of the union - you're only allowed what's common to all until you narrow (the next big idea). This feels annoying for a day, then you realize it's stopping a real crash: .toUpperCase() on a number throws at runtime.

Literal types - exact values, not just "some string"

A union of types is useful. A union of exact values is where it gets powerful.

📝 Literal type - a type that is one specific value, not a whole category. "shipped" is a type only the string "shipped" satisfies; 42 is a type only the number 42 satisfies. Combine them with | to describe a fixed set of allowed values.

Compare a bare string against a literal union for a fixed set of choices.

// Bare string: any string is accepted, including typos.
function setAlignLoose(value: string) { /* ... */ }
setAlignLoose("centre"); // accepted - but "centre" is a typo, no warning

// Literal union: only these three are allowed.
type Align = "left" | "right" | "center";
function setAlign(value: Align) { /* ... */ }

setAlign("center"); // fine
setAlign("centre"); // Error
Argument of type '"centre"' is not assignable to parameter of type 'Align'.

What just happened: With a bare string parameter, every string is fair game - the misspelled "centre" sails through and breaks something later. With type Align = "left" | "right" | "center", the checker rejects anything outside the set, and your editor autocompletes the three valid options. For any fixed set of choices - alignments, HTTP methods, status codes, sizes - a literal union beats a bare string every time.

💡 Why this is a big deal. A literal union turns "a value that's supposed to be one of these" (enforced by hope and comments) into "a value the compiler guarantees is one of these." Typos become compile errors, and you never write a default branch handling an impossible string.

Narrowing - proving which member you have

You saw the union problem: you can't use string-only operations on a string | number. The fix is narrowing, the heart of working with unions.

📝 Narrowing - writing a runtime check that lets TypeScript shrink a value's type within a branch of code. Inside an if that proves the value is a string, the checker narrows the type from string | number to just string, and every string operation is allowed.

You don't tell the checker the narrowed type - it reads your ordinary runtime check and works it out. The everyday tools:

  • typeof x === "string" - for primitives ("string", "number", "boolean", etc.).
  • "key" in obj - checks whether a property exists, narrowing to the variant that has it.
  • Array.isArray(x) - separates an array from a non-array member.
  • A truthiness check like if (x) - narrows string | null to string by ruling out null/undefined.

typeof narrowing fixing the broken format from earlier:

function format(id: string | number): string {
  if (typeof id === "string") {
    // In here, TypeScript knows id is a string.
    return id.toUpperCase();
  }
  // Down here, the string case is ruled out - id is a number.
  return id.toFixed(2);
}

console.log(format("abc")); // "ABC"
console.log(format(3.14159)); // "3.14"

What just happened: typeof id === "string" does double duty. At runtime it picks the right branch; at compile time TypeScript uses it to narrow id to string inside the if, so .toUpperCase() is allowed. After the if, the only remaining possibility is number, so .toFixed(2) needs no extra check. The checker followed your logic like a careful reader would, and the earlier error is gone.

Discriminated unions - modeling "one of N variants"

Narrowing a string | number is handy. The real power move is modeling objects that come in several shapes, and TypeScript has a pattern built for it.

📝 Discriminated union - a union of object types sharing a common literal field (the discriminant or tag), with a different value per variant. Checking that tag narrows the value to one specific shape, with all its fields available. Also called a tagged union.

A circle has a radius; a square has a side length. Give each a kind tag, union them, and a switch on kind narrows each case to its full shape.

interface Circle {
  kind: "circle";
  radius: number;
}
interface Square {
  kind: "square";
  side: number;
}
type Shape = Circle | Square;

function area(shape: Shape): number {
  switch (shape.kind) {
    case "circle":
      // Tag is "circle" → shape is a Circle here, so .radius exists.
      return Math.PI * shape.radius ** 2;
    case "square":
      // Tag is "square" → shape is a Square here, so .side exists.
      return shape.side ** 2;
  }
}

console.log(area({ kind: "circle", radius: 2 })); // 12.566...
console.log(area({ kind: "square", side: 3 })); // 9

What just happened: Shape is a union of two object types, each carrying a literal kind. Inside case "circle", TypeScript narrows shape to Circle, so shape.radius is valid and shape.side would be an error. The kind field unlocks the right shape in each branch - no guessing or casting.

💡 This is the answer to "how do I model one of N variants?" Discriminated unions are TypeScript's idiomatic replacement for enums-with-data, sealed classes, or a bag of optional fields. Each variant declares exactly the fields it has - no nullable radius that's only sometimes meaningful, no runtime confusion about which fields are valid. The shape is the documentation, and the checker enforces it.

Exhaustiveness checking - the compiler catches what you forgot

Discriminated unions have one more trick, and it's what makes the pattern indispensable on a real codebase: the compiler can force you to handle every variant, forever.

Suppose someone adds a Triangle to Shape next month but forgets to update area. Without protection, area silently returns undefined for triangles - a classic bug that hides until production. The fix: a default case assigning the value to a variable of type never.

📝 never - the type of a value that can't exist. If every variant has been handled, the value left in default has type never. Assigning anything other than never to a never variable is a compile error - exactly the alarm you want.

interface Circle { kind: "circle"; radius: number; }
interface Square { kind: "square"; side: number; }
interface Triangle { kind: "triangle"; base: number; height: number; } // newly added
type Shape = Circle | Square | Triangle;

function area(shape: Shape): number {
  switch (shape.kind) {
    case "circle":
      return Math.PI * shape.radius ** 2;
    case "square":
      return shape.side ** 2;
    default:
      // We forgot the "triangle" case, so shape is Triangle here - not never.
      const _exhaustive: never = shape; // Error
      return _exhaustive;
  }
}
Type 'Triangle' is not assignable to type 'never'.

What just happened: Because "triangle" isn't handled by any case, a Triangle value reaches default. We try to assign it to _exhaustive: never - but a Triangle is very much something, not never, so the checker errors, pointing at the exact function you forgot to update. Add case "triangle" and the leftover type becomes never again, and the error vanishes.

⚠️ This is the killer feature - don't skip the never line. Without it, adding a variant fails silently: area just returns undefined for the new shape. With it, every switch over the union lights up red the instant you extend it, handing you a checklist of exactly what to fix. On a large codebase, this turns a terrifying refactor into a mechanical one.

Recap

  1. Union types (A | B) describe a value that's one of several types. You can only use operations valid for every member until you narrow.
  2. Literal types ("left" | "right" | "center") describe a fixed set of exact values - beating a bare string with autocomplete and typo-rejection.
  3. Narrowing is the heart of unions: a runtime check (typeof, in, Array.isArray, truthiness) lets TypeScript shrink the type inside that branch, so you can safely use member-specific operations.
  4. Discriminated unions add a shared literal tag (kind) to each variant; switching on the tag narrows to that variant's full shape - TypeScript's answer to enums-with-data.
  5. ⚠️ Exhaustiveness checking with never in the default case makes the compiler error when you add a variant and forget to handle it - turning silent undefined bugs into a precise compile-time checklist.

Quick check

Lock in the three ideas that do the real work - why unions block operations, how narrowing fixes that, and what never buys you:

[
  {
    "q": "Why does calling `id.toUpperCase()` on a parameter typed `string | number` produce a compile error?",
    "choices": [
      "Until you narrow, you can only use operations valid for every member of the union - and `number` has no `.toUpperCase()`",
      "`toUpperCase` is deprecated in TypeScript and must be replaced with `toUppercase`",
      "Union types can never have methods called on them at all",
      "TypeScript requires you to cast every union to `any` before using it"
    ],
    "answer": 0,
    "explain": "A `string | number` value might be a number at runtime, and numbers have no `.toUpperCase()`. TypeScript only allows operations common to all members until a check (like `typeof id === \"string\"`) narrows the type to one that supports the call."
  },
  {
    "q": "In a discriminated union `type Shape = Circle | Square`, what makes a `switch (shape.kind)` able to access `shape.radius` inside `case \"circle\"`?",
    "choices": [
      "The shared literal `kind` tag lets TypeScript narrow `shape` to `Circle` in that branch, exposing its specific fields",
      "TypeScript guesses the shape based on which fields you try to access",
      "All fields of every variant are always available on every branch",
      "You must manually cast `shape` to `Circle` with `as` in each case"
    ],
    "answer": 0,
    "explain": "The literal `kind` field is the discriminant. Matching `case \"circle\"` proves `shape.kind` is `\"circle\"`, so TypeScript narrows `shape` to the `Circle` variant - making `radius` available and `side` an error, with no cast needed."
  },
  {
    "q": "What does assigning the leftover value to `const _exhaustive: never = shape;` in the `default` case accomplish?",
    "choices": [
      "If a new variant is added and left unhandled, it reaches `default` as a real type (not `never`), so the assignment errors and points you at the gap",
      "It makes the switch run faster by skipping the default branch at runtime",
      "It converts the union into an enum automatically",
      "It silences all type errors in the function"
    ],
    "answer": 0,
    "explain": "When every variant is handled, the value in `default` has type `never` and the assignment is legal. Add an unhandled variant and that value becomes a real type - not assignable to `never` - so the compiler errors at exactly the spot you forgot to update. That's exhaustiveness checking."
  }
]

Before the quiz: without looking back, say (or jot down) the core idea of this phase in your own words.

Check your understanding 3 questions

1. Why does calling `id.toUpperCase()` on a parameter typed `string | number` produce a compile error?

2. In a discriminated union `type Shape = Circle | Square`, what makes a `switch (shape.kind)` able to access `shape.radius` inside `case "circle"`?

3. What does assigning the leftover value to `const _exhaustive: never = shape;` in the `default` case accomplish?