TypeScript Patterns I Wish I Knew Earlier← Blog9 min read
TypeScriptPatternsDeveloper Experience

TypeScript Patterns I Wish I Knew Earlier

January 8, 2025 · 9 min read


Beyond Generics

Most TypeScript tutorials teach generics, utility types, and basic interfaces. That's the foundation. What I wish I'd learned earlier are the patterns that make large codebases provably correct — where the type system does the work instead of runtime checks.

Discriminated Unions

The most underused pattern in TypeScript. Instead of optional fields that may or may not be present:

// Bad: optional fields make every consumer defensive
type Result = {
  data?: User
  error?: string
  loading?: boolean
}

// Good: discriminated union — compiler knows which case you're in
type Result =
  | { status: "loading" }
  | { status: "success"; data: User }
  | { status: "error"; error: string }

With the discriminated union, TypeScript narrows the type inside each branch. No undefined checks needed.

function render(result: Result) {
  if (result.status === "success") {
    return result.data.name // TypeScript knows data exists here
  }
}

Template Literal Types

Combine string literals to express constrained string formats at the type level:

type EventName = `on${Capitalize<string>}`
// Valid: "onClick", "onChange", "onSubmit"
// Invalid: "click", "handleClick"

type RouteKey = `/${string}`
// Ensures all route strings start with /

I use this for API endpoint types, event names, and CSS custom property names. Catches typos at compile time instead of runtime.

Branded Types

Prevent mixing semantically different values that have the same underlying type:

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

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

function getPost(userId: UserId, postId: PostId) { }

// Fails at compile time — no more mixed-up ID arguments:
getPost(postId, userId) // Error: PostId not assignable to UserId

Before branded types, mixing up IDs in the wrong argument position was a runtime bug. Now it's a compile error.

The satisfies Operator

TypeScript 4.9 added satisfies — one of my favorite additions. It validates that a value matches a type without widening it:

type Config = Record<string, string | number>

// With "as": type is Config — you lose the specific keys
const config = {
  timeout: 5000,
  host: "localhost",
} as Config

// With "satisfies": type is { timeout: number, host: string }
const config = {
  timeout: 5000,
  host: "localhost",
} satisfies Config

config.timeout.toFixed() // Works — TypeScript knows it's a number, not string | number

Const Assertions

Prevent TypeScript from widening literal types:

// Without: type is string[]
const routes = ["/home", "/about", "/contact"]

// With: type is readonly ["/home", "/about", "/contact"]
const routes = ["/home", "/about", "/contact"] as const

type Route = typeof routes[number] // "/home" | "/about" | "/contact"

Now you have a type automatically kept in sync with your array. No manual union type to maintain.

The Underlying Principle

Each of these patterns moves validation from runtime to compile time. Runtime errors are caught in production. Compile errors are caught in your editor.

The goal is to make illegal states unrepresentable — to design your types so that code producing invalid state simply doesn't compile. That's where TypeScript earns its reputation.


← Back to Blog