Skip to content
➜cat blog/practical-typescript-patterns.md

Practical TypeScript: Patterns That Survived Production

Discriminated unions, branded types, const assertions, and the patterns I reach for daily after five years of TypeScript.

12 min

Five years of TypeScript across React, Vue, NestJS, and Nitro. These are the patterns I actually use, not the ones that look clever in blog posts but fall apart when a deadline hits.

Discriminated Unions for State

Every component that loads data has the same three states: loading, success, error. Modeling this as separate booleans (isLoading, hasError) creates impossible states. What does it mean when both isLoading and hasError are true?

Discriminated unions make impossible states unrepresentable:

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

Now the compiler enforces that data only exists when status is 'success', and error only exists when status is 'error'. You can't access data without narrowing first:

function render(state: AsyncState<Project[]>) {
  switch (state.status) {
    case 'idle':
      return null
    case 'loading':
      return <Spinner />
    case 'success':
      return <ProjectList projects={state.data} />
    case 'error':
      return <ErrorMessage message={state.error} />
  }
}

The exhaustive switch means adding a new status forces you to handle it everywhere.

Branded Types for IDs

This function takes two string arguments. Can you spot the bug?

function assignUserToProject(userId: string, projectId: string) {
  // ...
}

// Called with arguments swapped
assignUserToProject(projectId, userId)

TypeScript won't catch this because both are string. Branded types add a phantom property that makes each type distinct:

type UserId = string & { readonly __brand: 'UserId' }
type ProjectId = string & { readonly __brand: 'ProjectId' }

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

function projectId(id: string): ProjectId {
  return id as ProjectId
}

function assignUserToProject(userId: UserId, projectId: ProjectId) {
  // ...
}

assignUserToProject(projectId('abc'), userId('123'))
// Error: Argument of type 'ProjectId' is not assignable to parameter of type 'UserId'

The runtime cost is zero. The brand only exists at the type level. I use this for any function where swapping arguments would cause a silent, hard-to-debug error.

as const for Exhaustive Config

When you have a fixed set of values that code needs to iterate over, as const ensures the array is treated as a tuple of literals:

const ROLES = ['admin', 'editor', 'viewer'] as const
type Role = (typeof ROLES)[number]
// type Role = 'admin' | 'editor' | 'viewer'

The type and the runtime array stay in sync. Add a new role to the array, and the Role type updates automatically. Remove one, and every switch/map that used it breaks at compile time.

satisfies for Type-Checked Literals

Before satisfies, you had to choose: type a variable explicitly (losing literal types) or leave it inferred (losing validation). satisfies gives you both:

const routes = {
  home: '/',
  blog: '/blog',
  about: '/about',
} satisfies Record<string, string>

The value is validated against Record<string, string>, but the type retains the literal keys, so routes.home is '/', not string. Autocomplete works. Refactoring works. And if you typo a value to a non-string, the compiler catches it.

Generic Constraints That Document Intent

Unconstrained generics are a code smell. If a function works on "any object with an id," say so:

function findById<T extends { id: string }>(
  items: T[],
  id: string,
): T | undefined {
  return items.find((item) => item.id === id)
}

The constraint { id: string } is documentation that the compiler enforces. Anyone reading this signature knows immediately: this function works on collections of identifiable objects. If someone passes an array of strings, they get a clear error, not a runtime undefined.

Template Literal Types for Structured Strings

API routes, event names, CSS custom properties. All strings with structure. Template literal types enforce that structure:

type ApiRoute = `/api/${string}`
type EventName = `on${Capitalize<string>}`

function fetchApi(route: ApiRoute) { /* ... */ }

fetchApi('/api/projects')    // ok
fetchApi('/projects')         // error

I use this most for environment variable keys and route patterns where a typo would cause a silent failure.

Utility Types Worth Memorizing

These five cover 90% of type manipulation needs:

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

// Make specific fields required
type RequiredBy<T, K extends keyof T> = Omit<T, K> & Required<Pick<T, K>>

// Extract the resolved type of a Promise
type Awaited<T> = T extends Promise<infer U> ? U : T

// Make all properties readonly recursively
type DeepReadonly<T> = {
  readonly [P in keyof T]: T[P] extends object ? DeepReadonly<T[P]> : T[P]
}

// Strict version of Omit that errors on invalid keys
type StrictOmit<T, K extends keyof T> = Omit<T, K>

The Pattern I Avoid

Type gymnastics. If a type definition requires more than two levels of conditional types, it's a signal that the data model needs simplifying, not that the types need to be cleverer.

The goal of TypeScript isn't to model every possible state with perfect precision. It's to catch the mistakes that would otherwise reach production. Simple types that cover 95% of cases are better than complex types that cover 100% but nobody on the team can read.

Write types for the developer who joins the project next year. They won't appreciate your four-level conditional mapped type. They will appreciate type AsyncState<T> that makes the component's states obvious at a glance.