Back to BlogEngineering

TypeScript Advanced Patterns for Production Code

Discriminated unions, template literals, branded types, and other patterns that make TypeScript shine in large codebases.

Priya Patel Feb 20, 2026 11 min read
TypeScript Type Safety Software Engineering Patterns
TypeScript Advanced Patterns for Production Code

TypeScript's type system is far more powerful than most developers realize. Beyond basic annotations and interfaces, it offers a rich set of tools for encoding business logic directly into your type system — catching entire categories of bugs at compile time rather than runtime. After maintaining 40+ TypeScript codebases at Vaarak, these are the patterns we reach for most often.

TypeScript code on dark screen
Advanced TypeScript patterns transform your type system from documentation into active bug prevention

Discriminated Unions for State Machines

The single most impactful TypeScript pattern is discriminated unions for modeling state. Instead of a flat object with nullable fields (where you have to remember which fields are valid in which state), you create a union type where each variant has a literal discriminant field. The compiler then enforces that you handle every state correctly.

types/api-response.ts
type ApiResponse<T> =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "success"; data: T; updatedAt: Date }
  | { status: "error"; error: Error; retryCount: number };

function renderUsers(response: ApiResponse<User[]>) {
  switch (response.status) {
    case "idle":
      return <EmptyState />;
    case "loading":
      return <Skeleton />;
    case "success":
      // TypeScript knows response.data exists here
      return <UserList users={response.data} />;
    case "error":
      // TypeScript knows response.error exists here
      return <ErrorBanner message={response.error.message} />;
  }
}

Branded Types for Primitive Obsession

Primitive obsession — using raw strings and numbers for domain concepts — is a leading source of bugs. Is this string a user ID or an email? Is this number dollars or cents? Branded types add compile-time type safety to primitives without any runtime cost.

types/branded.ts
// Zero runtime cost — brands exist only at compile time
type Brand<T, B extends string> = T & { readonly __brand: B };

type UserId = Brand<string, "UserId">;
type OrderId = Brand<string, "OrderId">;
type Cents = Brand<number, "Cents">;
type Dollars = Brand<number, "Dollars">;

// Constructor functions
const UserId = (id: string) => id as UserId;
const OrderId = (id: string) => id as OrderId;

// Now the compiler prevents mixing them up
function getOrder(orderId: OrderId): Promise<Order> { /* ... */ }

getOrder(UserId("usr_123")); // ✗ Compile error!
getOrder(OrderId("ord_456")); // ✓ Works

Template Literal Types for API Routes

Template literal types let you create type-safe string patterns. We use them extensively for API route definitions, ensuring that route parameters, query strings, and path segments are all validated at compile time.

type HttpMethod = "GET" | "POST" | "PUT" | "DELETE";
type ApiVersion = "v1" | "v2";
type Resource = "users" | "orders" | "products";

// Generates: "GET /api/v1/users" | "GET /api/v1/orders" | "POST /api/v2/products" | ...
type ApiEndpoint = `${HttpMethod} /api/${ApiVersion}/${Resource}`;

// Extract params from route patterns
type ExtractParams<T extends string> =
  T extends `${string}:${infer Param}/${infer Rest}`
    ? Param | ExtractParams<Rest>
    : T extends `${string}:${infer Param}`
    ? Param
    : never;

// ExtractParams<"/users/:userId/orders/:orderId"> = "userId" | "orderId"

The satisfies Operator

The satisfies operator (TypeScript 4.9+) validates that a value matches a type without widening it. This is incredibly useful for configuration objects where you want type checking but also want to preserve the literal types for autocomplete.

const routes = {
  home: "/",
  blog: "/blog",
  blogPost: "/blog/:slug",
  dashboard: "/dashboard",
} satisfies Record<string, string>;

// Type is preserved as literal — autocomplete shows exact paths
routes.home; // type: "/"  not string
routes.blogPost; // type: "/blog/:slug"  not string

Exhaustive Pattern Matching

The never type can enforce exhaustiveness checking. If you add a new variant to a union type, every switch statement that handles it will produce a compile error until you add the new case — making it impossible to forget to handle new states.

function assertNever(x: never): never {
  throw new Error(`Unexpected value: ${x}`);
}

type PaymentStatus = "pending" | "processing" | "completed" | "failed" | "refunded";

function getStatusColor(status: PaymentStatus): string {
  switch (status) {
    case "pending": return "yellow";
    case "processing": return "blue";
    case "completed": return "green";
    case "failed": return "red";
    case "refunded": return "gray";
    default: return assertNever(status); // Compile error if any case is missing
  }
}

Enable strict mode in tsconfig.json and add noUncheckedIndexedAccess: true. These two settings alone catch more bugs than any linter rule.

These patterns aren't academic exercises — they're tools we use daily in production codebases. Start with discriminated unions (the highest impact-to-effort ratio), then add branded types to your domain model, and gradually introduce template literal types as your team's TypeScript fluency grows.

P

Priya Patel

Senior Backend Engineer