Skip to content
·8 min read

TypeScript Patterns I Actually Use in Production (Not the Fancy Stuff)

Most TypeScript tutorials teach fancy generics you never need. Here are the patterns I actually use: discriminated unions, branded types, narrowing, and exhaustive checks.

TypeScriptType SafetyWeb DevelopmentBest Practices

Most TypeScript tutorials teach fancy generics, mapped types, conditional types, and template literal types. I have been writing TypeScript for years and use maybe one of those things per project.

What I actually use, every day, are a handful of patterns that prevent real bugs. Discriminated unions. Branded types. Type guards. Exhaustive switches. None of them are clever. All of them pay off.

Here are the patterns I use in production, with examples.

Pattern 1: Discriminated unions for state

The most useful TypeScript pattern. Model every stateful thing in your app as a discriminated union.

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

Each variant has a status field (the discriminant) plus whatever data that variant carries. When you check status, TypeScript narrows the union:

function renderState(state: RequestState<User>) {
  switch (state.status) {
    case 'idle':
      return <IdlePrompt />           // state narrowed to idle
    case 'loading':
      return <Spinner />              // state narrowed to loading
    case 'success':
      return <UserCard user={state.data} />  // state.data available
    case 'error':
      return <ErrorView error={state.error} />  // state.error available
  }
}

In the success case, state.data is available without a type assertion. In the error case, state.error is available. TypeScript tracks which variant you are in.

This eliminates an entire class of bugs: "data is undefined when status is loading." Impossible by construction.

Pattern 2: Branded types

IDs are a classic bug source. A userId and an orderId are both strings. TypeScript cannot tell them apart. You accidentally pass an orderId to a function expecting a userId, and the compiler is happy.

Brand them:

type UserId = string & { readonly __brand: 'UserId' }
type OrderId = string & { readonly __brand: 'OrderId' }
 
function makeUserId(id: string): UserId {
  return id as UserId
}
 
function getUser(id: UserId): User { ... }
function getOrder(id: OrderId): Order { ... }

Now:

const uid = makeUserId('123')
const oid = makeOrderId('456')
 
getUser(uid)  // ok
getUser(oid)  // type error
getUser('123')  // type error, requires UserId

The brand exists only at compile time. At runtime, the value is a plain string. Zero performance cost.

Use branded types for:

  • Entity IDs (UserId, OrderId, ProductId)
  • API tokens (AccessToken, RefreshToken)
  • Validated values (EmailAddress, VerifiedPhoneNumber)

For validated values, the brand signals "this value has been checked." You cannot accidentally use a raw string where a validated email is required.

Pattern 3: Type guards for narrowing

Type guards are functions that return value is X. The compiler uses them for narrowing:

function isUser(value: unknown): value is User {
  return (
    typeof value === 'object' &&
    value !== null &&
    'id' in value &&
    'email' in value
  )
}
 
const data = await fetch('/api/me').then(r => r.json())
 
if (isUser(data)) {
  console.log(data.email)  // narrowed to User
} else {
  console.log('Not a user')
}

Without a type guard, you would write inline checks everywhere. With one, the check lives in one function and the narrowing works at every call site.

Zod schemas generate type guards automatically. schema.parse() returns the narrowed type, and schema.safeParse() returns a discriminated union of success or error.

Pattern 4: Exhaustive switches

The assertNever pattern catches missing cases at compile time.

function assertNever(value: never): never {
  throw new Error(`Unexpected value: ${JSON.stringify(value)}`)
}
 
type Role = 'admin' | 'editor' | 'viewer'
 
function canEdit(role: Role): boolean {
  switch (role) {
    case 'admin': return true
    case 'editor': return true
    case 'viewer': return false
    default:
      return assertNever(role)
  }
}

The argument to assertNever must be never. If the switch covers every case, the default branch is unreachable, and role is narrowed to never in the default. Compile-time check passes.

Later, you add 'guest' to the Role type:

type Role = 'admin' | 'editor' | 'viewer' | 'guest'

Now the switch no longer covers every case. In the default branch, role is narrowed to 'guest', which is not never. The compiler errors:

Argument of type '"guest"' is not assignable to parameter of type 'never'.

The error tells you exactly which case you forgot. This pattern scales beautifully with union changes.

Pattern 5: Skip unnecessary generics

Most generics in application code add complexity without value. Reach for them only when you genuinely need a type relationship between two values.

// Over-engineered
function fetchAndParse<T>(url: string): Promise<T> {
  return fetch(url).then(r => r.json() as T)
}
 
// Simpler
function fetchAndParse<T>(url: string): Promise<T> {
  return fetch(url).then(r => r.json() as T)
}

Actually, that one is fine. But consider:

// Bad — generic for the sake of generic
function createApiClient<TConfig extends Config>(config: TConfig) {
  return { config }
}
 
// Better — just use Config
function createApiClient(config: Config) {
  return { config }
}

Reach for generics when:

  • A function transforms one type to another (map<T, U>(arr: T[], fn: (x: T) => U): U[])
  • A function preserves a type (first<T>(arr: T[]): T | undefined)
  • A type relationship between arguments matters (prop<T, K extends keyof T>(obj: T, key: K): T[K])

Otherwise, write specific types. Application code is more readable without clever generics.

Pattern 6: const assertions for configs

For literal values that should not be widened:

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

The as const makes the array readonly and infers literal types. typeof ROLES[number] produces a union of the element types. Useful for any config where the runtime array should also be the source of truth for the type.

Pattern 7: Strict null checks

The single highest-leverage TypeScript setting. Turn it on:

// tsconfig.json
{
  "strictNullChecks": true
}

With this on, null and undefined are distinct from every other type. You cannot accidentally access .length on a possibly-undefined value. The compiler forces you to handle null cases.

If your project does not have strictNullChecks on, you are writing JavaScript with extra syntax. Fix that first.

Pattern 8: Hidden implementation with Pick and Omit

For APIs that expose a subset of internal types:

interface InternalUser {
  id: string
  email: string
  passwordHash: string
  createdAt: Date
}
 
type PublicUser = Pick<InternalUser, 'id' | 'email' | 'createdAt'>

PublicUser is exactly what you return from API endpoints. No accidental password hash leaks. The relationship is structural — change InternalUser and PublicUser stays in sync.

The honest truth

TypeScript's value is in the simple patterns. Discriminated unions prevent invalid states. Branded types prevent mix-ups. Type guards keep runtime checks in one place. Exhaustive switches catch missing cases when types change.

The fancy stuff — recursive conditional types, mapped type modifiers, template literal gymnastics — is occasionally useful in library code. In application code, it is usually overengineering.

Use the simple patterns consistently. They cover 95 percent of real-world TypeScript bugs.

TypeScript is most valuable when you stop trying to be clever. Model your states, brand your IDs, narrow with guards, and let the compiler catch what you missed.

Want help leveling up your TypeScript?

I refactor JavaScript and TypeScript apps for type safety, structure, and maintainability. Let's talk.

Frequently Asked Questions

What is a discriminated union in TypeScript?

A discriminated union is a union of object types that share a property (the discriminant) with literal values. When you check the discriminant, TypeScript narrows the union to the matching type. This is the most useful pattern for modeling state in TypeScript.

What is a branded type in TypeScript?

A branded type adds a unique marker property to a base type, like a UserId that is structurally a string but distinct from other strings. The compiler prevents you from passing a plain string or another branded type where it does not belong.

What is the assertNever pattern in TypeScript?

assertNever is a function that takes a value of type never and throws if called. Use it as the default case in switch statements over a union. If you add a new variant to the union later, the compiler errors because the switch no longer covers all cases.

Should I use generics in application TypeScript code?

Sparingly. Most generics in app code add complexity without value. Reach for generics when you need a type relationship between two values, like map over an array preserving the element type. Otherwise write two specific functions.