Skip to content
← Back to articles
10 min read

7 TypeScript Patterns That Eliminate Runtime Bugs

Practical TypeScript patterns for catching errors at compile time. Discriminated unions, exhaustive checks, branded types, and more.

7 TypeScript Patterns That Eliminate Runtime Bugs
In this post

TL;DR: Most TypeScript codebases use any as a crutch and string literals as identifiers. These 7 patterns — discriminated unions, exhaustive switches, branded types, satisfies, const assertions, template literal types, and result types — move error detection from runtime to compile time. Each one is a real refactor I’ve applied in production.


TypeScript gives you a type system. Whether you actually use it is another question.

Most codebases I’ve worked in treat TypeScript like “JavaScript with autocomplete.” They sprinkle interface on API responses, use any when things get awkward, and call it typed. The result is the worst of both worlds — you pay the TypeScript tax (syntax, build step, config) without getting the real payoff: compile-time safety.

These 7 patterns fix that. Each one is a concrete refactor you can apply today.

Table of Contents

1. Discriminated Unions for State Machines

The most common bug pattern in frontend code: a component that has multiple states but represents them with a bag of optional properties.

// ❌ The "bag of optionals" anti-pattern
interface ApiState {
  loading: boolean;
  data?: User[];
  error?: string;
}
// Nothing stops you from setting loading: true AND error: "fail"

Fix it with a discriminated union:

// ✅ Each state is explicit and mutually exclusive
type ApiState =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "success"; data: User[] }
  | { status: "error"; error: string };

function renderUsers(state: ApiState) {
  switch (state.status) {
    case "idle":
      return null;
    case "loading":
      return <Spinner />;
    case "success":
      return state.data.map(renderUser); // TS knows data exists
    case "error":
      return <ErrorBanner message={state.error} />; // TS knows error exists
  }
}

Now it’s impossible to access data in the error state. The compiler enforces the state machine.

2. Exhaustive Switch with never

Discriminated unions are only half the story. You need the compiler to scream when you add a new variant and forget to handle it.

function assertNever(x: never): never {
  throw new Error(`Unexpected value: ${x}`);
}

type PaymentMethod = 'card' | 'bank' | 'crypto';

function getIcon(method: PaymentMethod): string {
  switch (method) {
    case 'card':
      return '💳';
    case 'bank':
      return '🏦';
    case 'crypto':
      return '₿';
    default:
      return assertNever(method);
    // If someone adds "paypal" to the union,
    // this line throws a compile error — not a runtime bug
  }
}

This pattern turns “forgot to handle the new case” from a production bug into a red squiggly in your editor.

3. Branded Types for Semantic Safety

TypeScript’s structural typing means string is string. A user ID and an order ID are interchangeable. That’s a bug waiting to happen.

// Create distinct types that are structurally incompatible
type UserId = string & { readonly __brand: 'UserId' };
type OrderId = string & { readonly __brand: 'OrderId' };

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

function getOrder(orderId: OrderId) {
  /* ... */
}

const userId = createUserId('usr_123');
getOrder(userId); // ❌ Compile error! Can't pass UserId as OrderId

Zero runtime cost. The brand exists only in the type system and is erased at compile time. Use this for any ID, token, or value where mixing types would be a silent, devastating bug.

4. satisfies for Type-Checked Literals

Before satisfies, you had to choose: either widen the type with a type annotation (losing literal inference), or skip the annotation (losing validation).

type Route = {
  path: string;
  method: 'GET' | 'POST' | 'PUT' | 'DELETE';
};

// ❌ Type annotation: works but widens literals
const routes: Record<string, Route> = {
  users: { path: '/users', method: 'GET' },
};
// routes.users.method is "GET" | "POST" | "PUT" | "DELETE" — not "GET"

// ✅ satisfies: validates AND preserves literal type
const routes = {
  users: { path: '/users', method: 'GET' },
  createUser: { path: '/users', method: 'POST' },
} satisfies Record<string, Route>;
// routes.users.method is "GET" — the exact literal

Use satisfies anywhere you want the compiler to validate a shape while preserving the specific inferred type.

5. Const Assertions for Immutable Data

as const locks down an object or array to its most specific type and makes it deeply readonly.

// Without as const — types are widened
const config = { theme: 'dark', maxRetries: 3 };
// config.theme is string, config.maxRetries is number

// With as const — types are narrowed and frozen
const config = { theme: 'dark', maxRetries: 3 } as const;
// config.theme is "dark", config.maxRetries is 3
// config is Readonly — mutations are compile errors

This is essential for configuration objects, lookup tables, and any value that shouldn’t be mutated after declaration.

6. Template Literal Types for String Validation

TypeScript can validate string patterns at the type level.

type HexColor = `#${string}`;
type EventName = `on${Capitalize<string>}`;
type ApiEndpoint = `/api/${string}`;

function setColor(color: HexColor) {
  /* ... */
}
setColor('#ff0000'); // ✅
setColor('red'); // ❌ Compile error

function registerEvent(name: EventName, handler: () => void) {
  /* ... */
}
registerEvent('onClick', handleClick); // ✅
registerEvent('click', handleClick); // ❌ Compile error

Combine with mapped types for powerful API contracts:

type CrudEndpoints<T extends string> = {
  [K in `${'get' | 'create' | 'update' | 'delete'}${Capitalize<T>}`]: () => Promise<void>;
};

// Generates: getUser, createUser, updateUser, deleteUser
type UserApi = CrudEndpoints<'user'>;

7. Result Types Instead of Thrown Errors

Thrown exceptions break the type system. The function signature says nothing about what can go wrong.

// ❌ Caller has no idea this can throw
function parseConfig(raw: string): Config {
  const parsed = JSON.parse(raw); // throws on invalid JSON
  if (!parsed.version) throw new Error('Missing version');
  return parsed as Config;
}

// ✅ Error is part of the return type
type Result<T, E = Error> = { ok: true; value: T } | { ok: false; error: E };

function parseConfig(raw: string): Result<Config, 'invalid_json' | 'missing_version'> {
  try {
    const parsed = JSON.parse(raw);
    if (!parsed.version) return { ok: false, error: 'missing_version' };
    return { ok: true, value: parsed as Config };
  } catch {
    return { ok: false, error: 'invalid_json' };
  }
}

// Caller is forced to handle both cases
const result = parseConfig(input);
if (!result.ok) {
  // result.error is "invalid_json" | "missing_version" — fully typed
  console.error(result.error);
  return;
}
// result.value is Config — safe to use

This makes failure an explicit part of your API contract. No more try/catch gambling.

When to Apply These

Don’t refactor your entire codebase at once. Apply each pattern at the point of pain:

  • Getting weird undefined crashes? → Discriminated unions (#1) + exhaustive checks (#2)
  • Mixing up IDs or tokens? → Branded types (#3)
  • Config objects losing their literal types? → satisfies (#4) + const assertions (#5)
  • Accepting malformed strings? → Template literal types (#6)
  • Try/catch scattered everywhere? → Result types (#7)

Each pattern is zero runtime cost. They exist purely in the type system and are erased when TypeScript compiles to JavaScript. You get the safety for free.

Written by Jordan Thirkle

Stay-at-home dad building AI-accelerated products. I write code during naps and after bedtime — every post comes from real work, not theory.

X GITHUB LINKEDIN NEWSLETTER
0