Skip to lesson

learningtypescript.org / advanced / 14-advanced-patterns · lesson 21 of 25

TL;DR

Master advanced TypeScript patterns: discriminated unions, template literal types, conditional types, infer, and branded types.

Key concepts

  • TypeScript advanced types
  • template literal types
  • conditional types TypeScript
  • branded types

Advanced Patterns

TypeScript's type system is powerful enough to encode complex business rules. These advanced patterns let you catch entire categories of bugs at compile time.

What You'll Learn

By the end of this lesson you will build a request-state machine whose IDs cannot be mixed up with any other string in your program — a discriminated union for the states, a branded type for the IDs, and a switch the compiler proves you finished. Along the way you will learn to read a conditional type well enough to say what it produces for an input you have not been shown, which is the skill that separates using these types from copying them.

This lesson is not tied to a capstone milestone; it is the toolkit lesson, and it exists so the modelling you already do gets sharper rather than larger. You arrive able to narrow a union on a discriminant (Type Narrowing) and to derive one type from another with Partial, Pick and Omit (Utility Types). What is new is writing your own derivations — a type that takes a type as input and computes a different one back — and the erasure question that always follows: which half of this survives to run time, and which half was only ever a note to the compiler.

Discriminated Unions

Use a shared literal property to create type-safe unions with exhaustive handling.

interface Circle { kind: "circle"; radius: number }
interface Rectangle { kind: "rectangle"; width: number; height: number }
interface Triangle { kind: "triangle"; base: number; height: number }

type Shape = Circle | Rectangle | Triangle;

function area(shape: Shape): number {
  switch (shape.kind) {
    case "circle": return Math.PI * shape.radius ** 2;
    case "rectangle": return shape.width * shape.height;
    case "triangle": return 0.5 * shape.base * shape.height;
  }
}

function describe(shape: Shape): string {
  switch (shape.kind) {
    case "circle": return `Circle with radius ${shape.radius}`;
    case "rectangle": return `${shape.width}x${shape.height} rectangle`;
    case "triangle": return `Triangle with base ${shape.base}`;
  }
}

// Exhaustive — because each function declares a return type, adding a fourth
// Shape member makes these switches fall off the end, and the compiler errors
// with TS2366 ("Function lacks ending return statement"). Drop the return
// annotation and that safety net disappears; the explicit `never`-typed
// `default` from Debugging TypeScript is the version that always errors.
const shapes: Shape[] = [
  { kind: "circle", radius: 5 },
  { kind: "rectangle", width: 10, height: 3 },
  { kind: "triangle", base: 8, height: 6 }
];

shapes.forEach(shape => {
  console.log(`${describe(shape)}: area = ${area(shape).toFixed(2)}`);
});

// State machines with discriminated unions
type RequestState =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "success"; data: string[] }
  | { status: "error"; message: string };

function render(state: RequestState): string {
  switch (state.status) {
    case "idle": return "Ready to fetch";
    case "loading": return "Loading...";
    case "success": return `Got ${state.data.length} items`;
    case "error": return `Error: ${state.message}`;
  }
}

const states: RequestState[] = [
  { status: "idle" },
  { status: "loading" },
  { status: "success", data: ["a", "b", "c"] },
  { status: "error", message: "Network timeout" }
];

states.forEach(s => console.log(`[${s.status}] ${render(s)}`));

The comment above claims the return annotation is what makes those switches exhaustive. The next block drops that annotation, and it is worth watching both lanes: the compiler still objects, but it objects somewhere other than where the bug is, and the Run button carries on regardless.

Debug

Shape has three members and the switch handles two. Run it and the program completes, and the total comes out as a value that is not a number at all. Commit a hypothesis about what area() returns for the shape it does not handle, and about why the loop turns that into NaN rather than skipping it — then fix it so the total is 45. Worth doing afterwards: this seed is one the type lane also objects to, but it objects at the addition rather than at the missing case. Once you have found the bug, look at where you would have had to start reading if the compiler had been your only clue.

import assert from "node:assert";

type Shape =
| { kind: "circle"; radius: number }
| { kind: "rectangle"; width: number; height: number }
| { kind: "triangle"; base: number; height: number };

// No return annotation here, so the switch may fall off the end silently.
function area(shape: Shape) {
switch (shape.kind) {
	case "circle":
		return Math.round(Math.PI * shape.radius ** 2);
	case "rectangle":
		return shape.width * shape.height;
}
}

function totalArea(shapes: Shape[]): number {
let total = 0;
for (const s of shapes) {
	total += area(s);
}
return total;
}

const shapes: Shape[] = [
{ kind: "circle", radius: 2 },
{ kind: "rectangle", width: 4, height: 5 },
{ kind: "triangle", base: 6, height: 4 },
];

const total = totalArea(shapes);
assert.strictEqual(total, 45, "expected 45 (13 + 20 + 12 with the circle rounded), got " + total);
console.log("total area:", total);

Expected output:

total area: 45
Continue learning

Template Literal Types

Build string types from other types using template literal syntax.

// Basic template literals
type Color = "red" | "blue" | "green";
type Size = "sm" | "md" | "lg";
type ClassName = `${Size}-${Color}`;

// ClassName = "sm-red" | "sm-blue" | "sm-green" | "md-red" | ...

const valid: ClassName = "lg-blue";
console.log(`Class: ${valid}`);

// Event handler names
type EventName = "click" | "focus" | "blur";
type HandlerName = `on${Capitalize<EventName>}`;
// HandlerName = "onClick" | "onFocus" | "onBlur"

const handlers: Record<HandlerName, () => void> = {
  onClick: () => console.log("clicked"),
  onFocus: () => console.log("focused"),
  onBlur: () => console.log("blurred")
};

Object.entries(handlers).forEach(([name, fn]) => {
  console.log(`Calling ${name}:`);
  fn();
});

// CSS property builder
type CSSProperty = "margin" | "padding";
type Direction = "top" | "right" | "bottom" | "left";
type CSSDirectional = `${CSSProperty}-${Direction}`;

const styles: Partial<Record<CSSDirectional, string>> = {
  "margin-top": "10px",
  "padding-left": "20px",
  "margin-bottom": "5px"
};

Object.entries(styles).forEach(([prop, val]) => {
  console.log(`${prop}: ${val}`);
});

Conditional Types

Types that choose different results based on a condition.

Two mechanisms decide what a conditional type produces, and knowing them lets you predict a case nobody showed you.

The first is distribution. When the thing being tested is a naked type parameter — T on its own, not wrapped in anything — and you hand it a union, the conditional does not run once on the whole union. It runs once per member, and the results are unioned back together. So ToArray<string | number> is not (string | number)[]; it is ToArray<string> | ToArray<number>, which is string[] | number[]. Predict a new case with it: ToArray<"a" | "b" | never> distributes over two members (never is the empty union, so it contributes nothing) and gives "a"[] | "b"[]. Wrapping the parameter in a tuple — [T] extends [U] — switches distribution off, which is exactly why the IsEqual helper in the Testing lesson brackets both sides: it wants one verdict about the whole union, not one per member.

The second is infer. infer U is a pattern variable: it says "match the type against this shape, and if it fits, bind U to whatever filled that slot." T extends Array<infer U> ? U : T reads as "if T matches the shape Array<something>, give me the something; otherwise give back T unchanged." The false branch is the escape hatch for inputs that do not match, which is why Flatten<number> is number rather than an error. Predict a new case: Flatten<string[][]> matches Array<infer U> with U = string[], so it produces string[] — one layer, not all of them. A conditional type peels exactly the layer its pattern names.

// Basic conditional type
type IsString<T> = T extends string ? true : false;

type A = IsString<string>;    // true
type B = IsString<number>;    // false

// Flatten arrays
type Flatten<T> = T extends Array<infer U> ? U : T;

type C = Flatten<string[]>;   // string
type D = Flatten<number>;     // number

// Extract promise values (named Unwrap to avoid shadowing the built-in Awaited)
type Unwrap<T> = T extends Promise<infer U> ? U : T;

type E = Unwrap<Promise<string>>;  // string
type F = Unwrap<number>;           // number

// Practical: type-safe API response handler
interface ApiResponse<T> {
  data: T;
  status: number;
}

type ExtractData<T> = T extends ApiResponse<infer D> ? D : never;

type UserResponse = ApiResponse<{ name: string; email: string }>;
type UserData = ExtractData<UserResponse>; // { name: string; email: string }

// Runtime demonstration
function processResponse<T>(response: ApiResponse<T>): T {
  if (response.status >= 400) {
    throw new Error(`API error: ${response.status}`);
  }
  return response.data;
}

const response: UserResponse = {
  data: { name: "Alice", email: "alice@example.com" },
  status: 200
};

const user = processResponse(response);
console.log(`User: ${user.name} (${user.email})`);

// Distribute over unions
type ToArray<T> = T extends unknown ? T[] : never;
type G = ToArray<string | number>; // string[] | number[]

const strings: string[] = ["a", "b"];
const numbers: number[] = [1, 2];
const mixed: G = strings; // or numbers — both are valid
console.log(`Mixed: ${mixed}`);

Branded Types

Prevent mixing values that share the same underlying type but have different meanings.

// Without branding, these are interchangeable (bug-prone!)
// type UserId = string;
// type OrderId = string;

// With branding, they're distinct types
type Brand<T, B extends string> = T & { readonly __brand: B };

type UserId = Brand<string, "UserId">;
type OrderId = Brand<string, "OrderId">;
type Email = Brand<string, "Email">;

// Constructor functions validate and brand
function UserId(id: string): UserId {
  if (!id.startsWith("usr_")) throw new Error("Invalid user ID");
  return id as UserId;
}

function OrderId(id: string): OrderId {
  if (!id.startsWith("ord_")) throw new Error("Invalid order ID");
  return id as OrderId;
}

function Email(email: string): Email {
  if (!email.includes("@")) throw new Error("Invalid email");
  return email as Email;
}

// Functions accept only the correct branded type
function getUser(id: UserId): string {
  return `User(${id})`;
}

function getOrder(id: OrderId): string {
  return `Order(${id})`;
}

function sendEmail(to: Email, subject: string): string {
  return `Email to ${to}: ${subject}`;
}

// Usage — types prevent mixing up IDs
const userId = UserId("usr_123");
const orderId = OrderId("ord_456");
const email = Email("alice@example.com");

console.log(getUser(userId));
console.log(getOrder(orderId));
console.log(sendEmail(email, "Welcome!"));

// These would cause compile errors:
// getUser(orderId);  // Error: OrderId is not UserId
// getOrder(userId);  // Error: UserId is not OrderId
// sendEmail("plain string", "Hi"); // Error: string is not Email

console.log("\nAll branded operations succeeded!");

Recall

Without scrolling up: inside the switch (state.status) above, the success case can read state.data and the error case can read state.message — with no cast. You met this narrowing on a discriminant back in the Type Narrowing lesson. What makes it work, and why can't error see data?

Continue learning

Predict

UserId and OrderId are branded string types. getUser accepts only a UserId, but the code passes an OrderId. This exact code is checked with tsc --strict AND run by the tsx runner. Predict BOTH: what does tsc report, and what does the runner print?

type UserId = string & { readonly __brand: "UserId" };
type OrderId = string & { readonly __brand: "OrderId" };

function getUser(id: UserId): string {
return `User(${id})`;
}

const orderId = "ord_9" as OrderId;
console.log(getUser(orderId));
Continue learning

Mapped Types with Modifiers

Transform types by adding, removing, or changing properties.

// Make all properties readonly
type Immutable<T> = { readonly [K in keyof T]: T[K] };

// Make all properties mutable (remove readonly)
type Mutable<T> = { -readonly [K in keyof T]: T[K] };

// Make all properties required (remove optional)
type Complete<T> = { [K in keyof T]-?: T[K] };

// Make specific properties optional
type PartialBy<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;

interface Config {
  readonly host: string;
  readonly port: number;
  debug?: boolean;
  logLevel?: string;
}

// Remove readonly
type MutableConfig = Mutable<Config>;

// Make everything required
type FullConfig = Complete<Config>;

// Runtime demonstration
const config: FullConfig = {
  host: "localhost",
  port: 3000,
  debug: true,
  logLevel: "info"
};

console.log("Full config:", JSON.stringify(config));

// Why the conditional inside the mapped type? Immutable<T> above adds readonly
// only to the OUTER properties — state.user would be readonly, but state.user.name
// would still be writable. Recursing through the T[K] extends object branch is
// what carries the modifier all the way down. The shallow shape is the right
// choice when you own the nesting; the deep one when the value crosses a boundary.
type DeepReadonly<T> = {
  readonly [K in keyof T]: T[K] extends object ? DeepReadonly<T[K]> : T[K];
};

interface AppState {
  user: { name: string; prefs: { theme: string } };
  items: string[];
}

const state: DeepReadonly<AppState> = {
  user: { name: "Alice", prefs: { theme: "dark" } },
  items: ["a", "b"]
};

// state.user.name = "Bob"; // Error: readonly
console.log(`State: ${state.user.name}, theme: ${state.user.prefs.theme}`);

// Your turn — the pattern above is worked, this one is not. Write Optional<T>,
// a mapped type that makes every property optional AND nullable, so that
// { name?: string | null } is a legal Optional<{ name: string }>. Two of the
// pieces are already on this page: the [K in keyof T] header and the T[K] | null
// value from the Testing lesson's Nullable. The third is the modifier that ADDS
// optionality — it is the mirror image of the -? you saw in Complete<T>.

That last one is deliberately left to you, and the reason is worth naming: every mapped type on this page is the same three decisions — which keys to walk, which modifiers to add or strip, and what to compute for the value — and once you have made those three yourself, a mapped type you have never seen reads as a sentence rather than as syntax.

Try It Yourself

You've seen all four patterns in isolation. Now combine two of them into one program: a request-state machine (Discriminated Unions) whose IDs are branded strings (Branded Types). Three functions are stubbed with only their signatures. The spec for each is in the prose and the section headings — a branded constructor, a discriminant switch, and a terminal-state check. Run it as-is to see the first failure, implement that, then work down until it prints All checks passed.

Build

Finish the build. Three functions are stubbed with only their signatures; the checks below them fail until each returns the right value. Spec (from Discriminated Unions and Branded Types above): userId(raw) validates and brands a raw string as a UserId — reject an empty raw with a thrown Error whose message mentions 'empty', otherwise return it prefixed with 'usr_'. describe(state) renders one line per status by narrowing on the discriminant, one case per member, reading only that member's own fields (loading shows the user, success shows the item count, error shows the message); end the switch with an exhaustiveness never default. isTerminal(state) is true only for the success and error states. Checks run top to bottom, so the first failure is TODO 1 — implement it first, then work down.

import assert from "node:assert";

// A discriminated-union state machine with a branded ID. Domain locked — do not edit.
type UserId = string & { readonly __brand: "UserId" };
type RequestState =
| { status: "idle" }
| { status: "loading"; user: UserId }
| { status: "success"; user: UserId; data: string[] }
| { status: "error"; user: UserId; message: string };

// TODO 1
function userId(raw: string): UserId {
return "" as UserId;
}

// TODO 2
function describe(state: RequestState): string {
return "";
}

// TODO 3
function isTerminal(state: RequestState): boolean {
return false;
}

// --- Build checks: these must all pass. Do not edit below this line. ---
const u = userId("alice");
assert.strictEqual(u, "usr_alice", "TODO 1: userId should prefix the raw id with 'usr_'");
assert.throws(() => userId(""), /empty/i, "TODO 1: userId should reject an empty raw id");

assert.strictEqual(describe({ status: "idle" }), "idle", "TODO 2: idle renders 'idle'");
assert.strictEqual(describe({ status: "loading", user: u }), "loading usr_alice", "TODO 2: loading renders the user");
assert.strictEqual(describe({ status: "success", user: u, data: ["a", "b"] }), "success usr_alice: 2 items", "TODO 2: success renders the item count");
assert.strictEqual(describe({ status: "error", user: u, message: "boom" }), "error usr_alice: boom", "TODO 2: error renders the message");

assert.strictEqual(isTerminal({ status: "idle" }), false, "TODO 3: idle is not terminal");
assert.strictEqual(isTerminal({ status: "loading", user: u }), false, "TODO 3: loading is not terminal");
assert.strictEqual(isTerminal({ status: "success", user: u, data: [] }), true, "TODO 3: success is terminal");
assert.strictEqual(isTerminal({ status: "error", user: u, message: "x" }), true, "TODO 3: error is terminal");

console.log("All checks passed.");
console.log(describe({ status: "success", user: u, data: ["x", "y", "z"] }));
console.log("terminal:", isTerminal({ status: "error", user: u, message: "boom" }));

Expected output:

All checks passed.
success usr_alice: 3 items
terminal: true
Continue learning

Once it passes, try two variations and predict each before running:

  1. Render the array instead of its length. In describe, change the success case to return `success ${state.user}: ${state.data} items`; (drop .length). Predict which check fails first before running. Interpolating an array coerces it with join(","), so the line becomes success usr_alice: a,b items instead of success usr_alice: 2 items, and TODO 2's success check fails first — AssertionError: TODO 2: success renders the item count. The spec said "item count", and the count is .length, not the items.
  2. Count loading as terminal. Change isTerminal to return state.status !== "idle";. Predict which check fails first before running. Now every non-idle state reads as terminal, including loading. TODO 3's loading check fails first with AssertionError: TODO 3: loading is not terminal — true !== false. "Terminal" means the request is finished (success or error); loading is still in flight, so "not idle" is the wrong boundary.

Here is the frame the five lines drop into. The union is named Figure rather than Shape because it is not the Shape from the top of this lesson — it has a square member that one does not:

type Figure = { kind: "circle"; radius: number } | { kind: "square"; side: number };
const figures: Figure[] = [{ kind: "square", side: 6 }];
// the five shuffled lines go here

Arrange the code

The frame above supplies the two names the lines do not introduce for themselves: the Figure union and the figures array holding one square. The five lines below pick a figure out of that array, compute its area by narrowing on the discriminant, round the result, build a label, and log it. They are shuffled. Put them back in an order that runs - then answer what the puzzle is really testing: which line could not have moved, and what does the runner do if it does?

  1. console.log(label);
  2. const rounded = Math.round(area);
  3. const label = `${shape.kind}: ${rounded}`;
  4. const area = shape.kind === "circle" ? Math.PI * shape.radius ** 2 : shape.side ** 2;
  5. const shape: Figure = figures[0];
Continue learning

Transfer

A TypeScript discriminated union tags each member with a literal field, and a switch on that tag is checked for exhaustiveness. Rust has enums, whose variants carry their own data, and a match on one is checked for exhaustiveness too. The surfaces are very different — Rust has no literal string tag you write yourself, and no annotation to remember. What is the invariant that carries across, and what genuinely changed?

Continue learning

Key Takeaways

  • Discriminated unions with switch provide exhaustive, type-safe branching
  • Template literal types build string types from unions: `${A}-${B}`
  • Conditional types (T extends U ? X : Y) enable type-level logic
  • infer extracts types from inside other types (arrays, promises, functions)
  • Branded types prevent mixing values with the same underlying type
  • Mapped type modifiers (-readonly, -?) transform property attributes

Pro Tip: Start with discriminated unions — in most application code they turn out to be the pattern you actually needed (no survey backs a specific share, so treat that as a rule of thumb, not a measurement). Only reach for conditional types and infer when you're building reusable library types. For application code, simple unions with exhaustive switches are almost always clearer and sufficient.

Next Steps

You've added discriminated unions, template literals, conditional types, and branded types to your toolkit — the patterns that encode business rules in the type system. Next, you'll make sure the code that uses them actually behaves: writing type-safe tests with assertion patterns, typed mocks, and compile-time type checks.

Ready to continue? Head to Testing!

Two-tier handoff: this document is the complete reading surface. Continue learning for stateful practice, progress, and real sandbox execution.