DEV Community

Moonlit Capy
Moonlit Capy

Posted on • Originally published at blog.elunari.uk

TypeScript Patterns Every Developer Should Know in 2026

TypeScript has matured far beyond "JavaScript with types." The type system is powerful enough to encode complex business logic at compile time. Here are the patterns that make the biggest difference in production codebases.

Discriminated Unions

Model states where different variants carry different data. TypeScript narrows the type automatically based on the discriminant.

type AsyncState<T> =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "success"; data: T }
  | { status: "error"; error: Error }

function renderState<T>(state: AsyncState<T>) {
  switch (state.status) {
    case "idle": return "Ready"
    case "loading": return "Loading..."
    case "success": return `Data: ${JSON.stringify(state.data)}`
    case "error": return `Error: ${state.error.message}`
  }
}
Enter fullscreen mode Exit fullscreen mode

This is the foundation of type-safe state management. Every React app with async data should use this pattern instead of separate isLoading, isError, data booleans.

Branded Types

Prevent mixing up values that are the same primitive type but represent different things.

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

function createUserId(id: string): UserId {
  return id as UserId
}

function getOrder(orderId: OrderId) { /* ... */ }

const userId = createUserId("user_123")
// getOrder(userId) // Compile error! Can't pass UserId where OrderId expected
Enter fullscreen mode Exit fullscreen mode

Zero runtime cost. The brand exists only in the type system. Use this for any ID type, currency amount, or validated string.

Template Literal Types

Generate string unions from combinations:

type EventName = `${"user" | "order"}.${"created" | "updated" | "deleted"}`
// Result: "user.created" | "user.updated" | "user.deleted" | "order.created" | "order.updated" | "order.deleted"

type CSSProperty = `${string}-${string}`
type HexColor = `#${string}`
Enter fullscreen mode Exit fullscreen mode

This is powerful for event systems, CSS-in-JS, and any API where string patterns matter.

Result Types for Error Handling

Stop throwing exceptions. Return typed results instead:

type Result<T, E = Error> =
  | { ok: true; value: T }
  | { ok: false; error: E }

function parseConfig(input: string): Result<Config, ParseError> {
  try {
    const config = JSON.parse(input)
    return { ok: true, value: config }
  } catch (e) {
    return { ok: false, error: new ParseError(e.message) }
  }
}

const result = parseConfig(input)
if (result.ok) {
  console.log(result.value) // TypeScript knows this is Config
} else {
  console.error(result.error) // TypeScript knows this is ParseError
}
Enter fullscreen mode Exit fullscreen mode

The caller is forced to handle both cases. No more uncaught exceptions in production.

Exhaustive Matching

Use the never type to ensure you handle every case in a union:

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

function handleStatus(status: AsyncState<unknown>) {
  switch (status.status) {
    case "idle": return "Ready"
    case "loading": return "Loading..."
    case "success": return "Done"
    case "error": return "Failed"
    default: return assertNever(status) // Compile error if a case is missing
  }
}
Enter fullscreen mode Exit fullscreen mode

When you add a new variant to the union, TypeScript will flag every switch statement that doesn't handle it.

Wrapping Up

Start with discriminated unions and exhaustive matching — they change how you think about state management. Layer in branded types and Result types as your codebase grows. The goal: make illegal states unrepresentable.

These patterns have zero runtime overhead. They exist purely in the type system, disappearing completely after compilation. That's the beauty of TypeScript done right.


Want more dev content? Check out the blog or buy me a coffee.

Top comments (0)