Skip to editor content
learningtypescript.orglesson 21 of 25

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.

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 25-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)}`));

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 17-type-narrowing. What makes it work, and why can't error see data?

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.

// 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!");

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));

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));

// Deep readonly
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}`);

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

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 terminaltrue !== false. "Terminal" means the request is finished (success or error); loading is still in flight, so "not idle" is the wrong boundary.

Arrange the code

Reassemble a program that brands a raw string into a prefixed id, embeds it in a label, and logs the label. The lines are shuffled; each const consumes the binding above it, so only one order runs top-to-bottom and prints user=usr_ada.

  1. const brand = (raw: string): string => `usr_${raw}`;
  2. console.log(label);
  3. const id = brand("ada");
  4. const label = `user=${id}`;

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 — they solve 80% of cases where you'd reach for advanced patterns. 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!

Next lesson

Testing

Write type-safe tests in TypeScript with assertion patterns, mocking, and test organization for reliable, maintainable test suites.

25 min