Blog
/3 min read

TypeScript Patterns I Actually Use Every Day

Battle-tested TypeScript patterns for real-world applications — discriminated unions, branded types, exhaustive checks, and more.

TypeScriptPatternsFrontendArchitecture

I see a lot of incredibly complex TypeScript gymnastics on Twitter, but 99% of it never makes it into my production codebases. After writing TS full-time for years, here are the actual patterns I reach for constantly.

Stop Using Booleans for State

Modeling state with isLoading and isError booleans is a trap. I prefer discriminated unions because they force the compiler to act as a state machine enforcer.

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

If you try to access data before checking that status === 'success', the compiler yells at you. It completely eliminates the need for all those defensive if (data) checks scattered throughout your UI.

Branded Types for Safety

A string that holds a user ID is fundamentally different from a string holding an email address, but TypeScript doesn't care. Branded types fix this without adding any runtime overhead.

typescript
type Brand<T, B extends string> = T & { readonly __brand: B };

type UserId = Brand<string, 'UserId'>;
type Email = Brand<string, 'Email'>;

const createUserId = (id: string): UserId => id as UserId;
const createEmail = (email: string): Email => email as Email;

Now if you accidentally pass an email string into a function expecting a user ID, the build fails. I use this constantly for database primary keys.

The satisfies Operator is Underrated

I use satisfies everywhere now. It validates that an object matches a shape, but it doesn't erase the literal types of the keys and values like a standard type annotation does.

typescript
const ROUTES = {
  home: '/',
  blog: '/blog',
  contact: '/contact',
} satisfies Record<string, string>;

// Type is still { home: "/"; blog: "/blog"; contact: "/contact" }
// NOT Record<string, string>

It is perfect for defining theme tokens, route configs, or anything where you want autocomplete on the exact values later on.

Exhaustive Switch Statements

When dealing with those discriminated unions, you want to guarantee that you haven't forgotten a state. I always throw an exhaustive check at the bottom.

typescript
const assertNever = (value: never): never => {
  throw new Error(`Unhandled value: ${JSON.stringify(value)}`);
};

const getLabel = (status: AsyncState<unknown>['status']): string => {
  switch (status) {
    case 'idle':
      return 'Ready';
    case 'loading':
      return 'Loading...';
    case 'success':
      return 'Done';
    case 'error':
      return 'Failed';
    default:
      return assertNever(status); // TS complains here if you missed a case!
  }
};

If a backend engineer adds a new status to the API type, the frontend build instantly breaks exactly where I need to handle it.

The One I Still Struggle With

I'll be honest, the infer keyword inside conditional types still breaks my brain sometimes. I can read it, but every time I have to write a complex generic using it, I end up staring at my monitor for 20 minutes. But that's part of the fun.

erginos.io — 2026