Tutorials
|stacknotice.com
13 min left|
0%
|2,600 words
Tutorials

TypeScript Patterns Senior Devs Use That Nobody Teaches (2026)

Branded types, discriminated unions, the Result pattern, exhaustive switches — the TypeScript patterns that actually prevent production bugs.

May 28, 202613 min read
Share:
TypeScript Patterns Senior Devs Use That Nobody Teaches (2026)

Most TypeScript tutorials stop at generics and interfaces.

Senior devs use a completely different set of patterns — ones that make impossible states actually impossible, catch entire categories of bugs at compile time, and turn runtime crashes into type errors. This is what separates production-grade TypeScript from TypeScript that just happens to compile.

1. Branded types — make invalid IDs impossible

A userId and orderId are both strings. TypeScript won't stop you from passing one where the other is expected:

function getUser(userId: string) { /* ... */ }
function getOrder(orderId: string) { /* ... */ }
 
const orderId = "ord_123"
getUser(orderId) // TypeScript: fine. Production: disaster.

Branded types fix this:

type Brand<T, B> = T & { readonly _brand: B }
 
type UserId = Brand<string, 'UserId'>
type OrderId = Brand<string, 'OrderId'>
 
function createUserId(id: string): UserId {
  return id as UserId
}
 
function getUser(userId: UserId) { /* ... */ }
function getOrder(orderId: OrderId) { /* ... */ }
 
const orderId = createUserId("ord_123") as unknown as OrderId
getUser(orderId) // TypeScript error — correct!

Use branded types at your domain boundaries: user IDs, order IDs, money amounts in cents (never in floating point), validated email addresses. The runtime cost is zero. The safety gain is massive.

type Cents = Brand<number, 'Cents'>
type Email = Brand<string, 'Email'>
 
function createEmail(input: string): Email {
  if (!input.includes('@')) throw new Error('Invalid email')
  return input as Email
}
 
// Now this function can only receive a validated email
function sendWelcomeEmail(to: Email, amount: Cents) { /* ... */ }

2. Discriminated unions — model state, not data

A common anti-pattern: optional fields everywhere.

// Bad — what's actually valid here?
type AsyncState<T> = {
  data?: T
  error?: Error
  isLoading: boolean
}

Can data and error both be set? Is isLoading: true with data defined valid? TypeScript has no idea. You'll be writing if (data && !error) everywhere.

Discriminated unions model the actual states:

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

Now TypeScript knows exactly what fields are available in each branch:

function render<T>(state: AsyncState<T>) {
  switch (state.status) {
    case 'idle':
      return <EmptyState />
    case 'loading':
      return <Spinner />
    case 'success':
      return <DataView data={state.data} /> // data is guaranteed here
    case 'error':
      return <ErrorView error={state.error} /> // error is guaranteed here
  }
}

Apply this to anything with distinct states: payment status, form state, auth state, API responses. The more states a piece of UI can be in, the more discriminated unions save you.

3. The Result type — explicit error handling

throw is invisible. Any function can throw, nothing in the type signature tells you, and forgetting a try/catch causes a production crash.

The Result pattern makes errors part of the type:

type Result<T, E = Error> =
  | { ok: true; value: T }
  | { ok: false; error: E }
 
function ok<T>(value: T): Result<T> {
  return { ok: true, value }
}
 
function err<E = Error>(error: E): Result<never, E> {
  return { ok: false, error }
}
async function fetchUser(id: string): Promise<Result<User>> {
  try {
    const user = await db.query.users.findFirst({ where: eq(users.id, id) })
    if (!user) return err(new Error('User not found'))
    return ok(user)
  } catch (e) {
    return err(e instanceof Error ? e : new Error('Unknown error'))
  }
}
 
// Caller is forced to handle both cases
const result = await fetchUser(id)
if (!result.ok) {
  return NextResponse.json({ error: result.error.message }, { status: 404 })
}
 
const user = result.value // TypeScript knows this is User

The function signature Promise<Result<User>> tells callers: this can fail, and you must handle it. No implicit throws, no silent crashes.

For a more complete implementation with utilities like mapResult, flatMapResult, check the neverthrow library.

4. satisfies — validate without widening

satisfies was added in TypeScript 4.9 and most developers still don't use it. It validates that a value matches a type without losing the literal type information.

The problem it solves:

const config: Record<string, string> = {
  apiUrl: 'https://api.example.com',
  timeout: '5000',
}
 
// TypeScript only knows it's a string, not the literal value
config.apiUrl // type: string — no autocomplete

With satisfies:

const config = {
  apiUrl: 'https://api.example.com',
  timeout: '5000',
} satisfies Record<string, string>
 
// TypeScript knows the exact shape AND validates the constraint
config.apiUrl // type: 'https://api.example.com' — literal preserved

The killer use case is route configuration:

type Route = {
  path: string
  method: 'GET' | 'POST' | 'PUT' | 'DELETE'
  requiresAuth: boolean
}
 
const routes = {
  getUser: { path: '/users/:id', method: 'GET', requiresAuth: true },
  createUser: { path: '/users', method: 'POST', requiresAuth: false },
} satisfies Record<string, Route>
 
// Full autocomplete on each route
routes.getUser.method // type: 'GET'
routes.createUser.path // type: '/users'

5. as const with inference — zero-cost enums

TypeScript enums have issues: they compile to objects, they're not tree-shakeable, and they behave unexpectedly. Senior devs avoid them.

The pattern that replaces enums:

const PLAN = {
  FREE: 'free',
  PRO: 'pro',
  ENTERPRISE: 'enterprise',
} as const
 
type Plan = typeof PLAN[keyof typeof PLAN]
// type Plan = 'free' | 'pro' | 'enterprise'
 
// Use PLAN.FREE everywhere — no magic strings, full autocomplete
function canAccessFeature(plan: Plan): boolean {
  return plan === PLAN.PRO || plan === PLAN.ENTERPRISE
}

Same pattern for status codes, event names, permission keys:

const PERMISSION = {
  READ: 'read',
  WRITE: 'write',
  ADMIN: 'admin',
} as const
 
type Permission = typeof PERMISSION[keyof typeof PERMISSION]

This compiles to a plain object. No runtime overhead, full type safety, tree-shakeable.

6. Exhaustive switches with never

When you add a new value to a union, TypeScript won't automatically tell you about every place in the codebase that needs updating. Unless you set it up to.

type Plan = 'free' | 'pro' | 'enterprise'
 
function getPlanLimit(plan: Plan): number {
  switch (plan) {
    case 'free': return 10
    case 'pro': return 100
    case 'enterprise': return Infinity
    // If you add 'team' to Plan, this compiles fine
    // and silently returns undefined at runtime
  }
}

The fix — use never to assert exhaustiveness:

function assertNever(x: never): never {
  throw new Error(`Unhandled case: ${JSON.stringify(x)}`)
}
 
function getPlanLimit(plan: Plan): number {
  switch (plan) {
    case 'free': return 10
    case 'pro': return 100
    case 'enterprise': return Infinity
    default: return assertNever(plan)
    // Now if you add 'team' to Plan:
    // Argument of type 'string' is not assignable to parameter of type 'never'
  }
}

Add 'team' to the Plan union and TypeScript immediately flags every switch that doesn't handle it. This is how you scale a codebase without breaking things silently.

7. ReturnType and Awaited — infer instead of duplicate

Never write the return type of a function manually when TypeScript can infer it. Duplicated types drift.

// Don't do this — two sources of truth
async function getUser(id: string) {
  return db.query.users.findFirst({ where: eq(users.id, id) })
}
 
type User = {
  id: string
  email: string
  // ...
}

Let TypeScript infer it:

async function getUser(id: string) {
  return db.query.users.findFirst({ where: eq(users.id, id) })
}
 
// Infer from the function itself
type GetUserResult = Awaited<ReturnType<typeof getUser>>
// type GetUserResult = { id: string; email: string; ... } | undefined

This is especially powerful with Drizzle or Prisma — the return type matches the actual schema, always. No manual type maintenance.

Other useful inference utilities:

type Params = Parameters<typeof getUser>[0]  // string
type FirstArg = Parameters<typeof fn>[0]
type InstanceType<typeof MyClass>

8. Template literal types — enforce string formats

Template literal types let you describe string patterns at the type level:

type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'
type ApiVersion = 'v1' | 'v2'
type ApiRoute = `/${ApiVersion}/${string}`
 
// TypeScript enforces the pattern
const route: ApiRoute = '/v1/users'    // OK
const bad: ApiRoute = '/users'         // Error — missing version
const alsoBad: ApiRoute = '/v3/users'  // Error — invalid version

Practical use case — event names in a typed event system:

type Entity = 'user' | 'order' | 'payment'
type Action = 'created' | 'updated' | 'deleted'
type EventName = `${Entity}.${Action}`
// 'user.created' | 'user.updated' | 'user.deleted' | 'order.created' | ...
 
function emit(event: EventName, payload: unknown) { /* ... */ }
 
emit('user.created', { id: '123' })  // OK
emit('user.removed', { id: '123' })  // Error — 'removed' not in Action

CSS property enforcement, API endpoint validation, enum-like string combinations — template literal types handle all of it.

9. Type guards vs type assertions

as is a lie you tell TypeScript. Use it as little as possible.

// Dangerous — you're promising something TypeScript can't verify
const user = response.data as User

Type guards let TypeScript verify the shape at runtime, then narrow the type:

function isUser(value: unknown): value is User {
  return (
    typeof value === 'object' &&
    value !== null &&
    'id' in value &&
    typeof (value as any).id === 'string' &&
    'email' in value &&
    typeof (value as any).email === 'string'
  )
}
 
const data = await response.json()
 
if (isUser(data)) {
  // TypeScript knows data is User here
  console.log(data.email) // safe
}

For validation at API boundaries, use Zod instead of manual type guards — it generates the type guard automatically:

import { z } from 'zod'
 
const UserSchema = z.object({
  id: z.string(),
  email: z.string().email(),
  createdAt: z.string().datetime(),
})
 
type User = z.infer<typeof UserSchema>
 
// Runtime validation + type narrowing in one step
const user = UserSchema.parse(await response.json())

For a full guide on Zod patterns, see the complete Zod guide for TypeScript.

10. Generic constraints — restrict what generics accept

Unconstrained generics are often too permissive:

// T can be anything — even null or a function
function getField<T>(obj: T, key: string) {
  return (obj as any)[key]
}

Constraints make generics precise:

// T must be an object with string keys
function getField<T extends Record<string, unknown>>(obj: T, key: keyof T): T[keyof T] {
  return obj[key]
}
 
// K must be a key of T
function pick<T extends object, K extends keyof T>(obj: T, keys: K[]): Pick<T, K> {
  const result = {} as Pick<T, K>
  for (const key of keys) {
    result[key] = obj[key]
  }
  return result
}
 
// Usage
const user = { id: '1', email: 'x@y.com', password: 'secret' }
const safe = pick(user, ['id', 'email'])
// type: { id: string; email: string } — password excluded

The Pick, Omit, Partial, and Required utility types use this internally.

Putting it together — a real example

Here's how these patterns combine in a real service layer:

type UserId = Brand<string, 'UserId'>
 
const UserSchema = z.object({
  id: z.string().transform(id => id as UserId),
  email: z.string().email(),
  plan: z.enum(['free', 'pro', 'enterprise']),
  createdAt: z.string().datetime(),
})
 
type User = z.infer<typeof UserSchema>
type UserPlan = User['plan']
 
type UserState =
  | { status: 'loading' }
  | { status: 'success'; user: User }
  | { status: 'error'; error: Error }
 
async function fetchUser(id: UserId): Promise<Result<User>> {
  try {
    const raw = await db.query.users.findFirst({ where: eq(users.id, id) })
    if (!raw) return err(new Error('Not found'))
    return ok(UserSchema.parse(raw))
  } catch (e) {
    return err(e instanceof Error ? e : new Error('Unknown'))
  }
}
 
function getPlanLimit(plan: UserPlan): number {
  switch (plan) {
    case 'free': return 10
    case 'pro': return 100
    case 'enterprise': return Infinity
    default: return assertNever(plan)
  }
}

Every possible failure is visible. Every invalid state is impossible to represent. Adding a new plan value breaks exactly the places that need updating.

When NOT to use these patterns

These patterns have a cost — more code, more upfront thinking. Don't apply them everywhere:

  • Branded types: use at domain boundaries, not for every string in your codebase
  • Result type: use for operations that legitimately fail, not for simple getters
  • Discriminated unions: use when a type genuinely has distinct states
  • Exhaustive switches: use when adding values to a union is likely

The goal isn't to write the most TypeScript possible. It's to make bugs impossible without the complexity tax becoming worse than the bugs themselves.

For the broader setup these patterns fit into, see the senior full-stack project setup guide. For how AI tools like Claude Code can help you apply these patterns consistently, see the CLAUDE.md ultimate guide.

#typescript#nextjs#webdev#programming#devtools
Share:

Enjoyed this article?

Join 2,400+ developers getting weekly insights on Claude Code, React, and AI tools.

No spam. Unsubscribe anytime. By subscribing you agree to our Privacy Policy.