Engineering

Five TypeScript Patterns That Eliminated Half Our Production Bugs

February 8, 2026 · 10 min read

Last September, our team at a mid-size fintech startup hit a breaking point. We were averaging three production incidents per week, and almost every root cause analysis pointed to the same class of problem: a value that should not have been possible made it through the system anyway. A null where we expected a number. A status field set to a string our switch statement did not handle. An API response shaped differently than the type we had optimistically asserted.

We spent a quarter refactoring our TypeScript codebase with five specific patterns. Six months later, our incident rate dropped by fifty-two percent. Here is what we changed and why it worked.

1. Discriminated Unions for State Machines

Before the refactor, our application state for asynchronous operations looked like this:

interface AsyncState {
  loading: boolean;
  error: string | null;
  data: User | null;
}

This type permits impossible states. You can have loading: true and error: "something went wrong" simultaneously. You can have data: someUser alongside loading: true. Every component consuming this state needed defensive checks that were easy to forget and tedious to maintain.

We replaced these flat interfaces with discriminated unions, sometimes called tagged unions:

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

The status field acts as a discriminant. When you narrow on it with a switch statement or conditional, TypeScript knows exactly which properties exist. You cannot access data without first confirming that the status is "success". The compiler enforces this at build time, not at two in the morning when your on-call engineer is half asleep.

This single pattern accounted for roughly a third of our bug reduction. Impossible states became unrepresentable.

2. Branded Types for Primitive Obsession

Our codebase passed strings everywhere: user IDs, order IDs, session tokens, email addresses. They were all typed as string, which meant the compiler was perfectly happy to let you pass a user ID where an order ID was expected.

Branded types solve this with a phantom property that exists only at the type level:

type UserId = string & { readonly __brand: "UserId" };
type OrderId = string & { readonly __brand: "OrderId" };

function getUser(id: UserId): Promise<User> { ... }

const orderId = "ord_abc123" as OrderId;
getUser(orderId); // Type error!

The __brand property is never present at runtime. It costs nothing in bundle size or performance. But it gives the compiler enough information to distinguish between semantically different strings. We created branded types for every domain identifier in our system: UserId, OrderId, AccountId, Currency, and EmailAddress.

The initial migration was tedious. The payoff was immediate. An entire category of argument-ordering bugs vanished.

3. Exhaustive Switch Statements with never

Discriminated unions are powerful, but they introduce a maintenance risk: when you add a new variant to the union, every switch statement that handles it must be updated. If you forget one, TypeScript will not warn you by default.

The fix is an exhaustiveness check using the never type:

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

function handleStatus(state: AsyncState<User>) {
  switch (state.status) {
    case "idle":
      return renderEmpty();
    case "loading":
      return renderSpinner();
    case "error":
      return renderError(state.error);
    case "success":
      return renderUser(state.data);
    default:
      return assertNever(state);
  }
}

If someone adds a fifth status variant, say "retrying", every switch statement with a default: assertNever clause will produce a compile-time error. The new variant's type will not be assignable to never, and the developer will be forced to handle it explicitly.

We enforced this with an ESLint rule that flagged switch statements on discriminated unions without an exhaustiveness check. It caught three missing handlers in the first week.

4. readonly by Default

Mutation bugs are notoriously difficult to trace. A function receives an array, sorts it in place, and suddenly a seemingly unrelated component re-renders with data in the wrong order. A utility function modifies an object property, and the original reference is silently corrupted upstream.

We adopted a convention: every interface property, function parameter, and return type is readonly unless mutation is explicitly required and documented.

interface Transaction {
  readonly id: TransactionId;
  readonly amount: number;
  readonly currency: Currency;
  readonly status: TransactionStatus;
  readonly createdAt: Date;
}

function processTransactions(
  txns: readonly Transaction[]
): readonly Transaction[] {
  // txns.sort() would be a compile error
  return [...txns].sort(byDate);
}

The key insight is that readonly does not prevent change at runtime. JavaScript does not enforce it. But it forces the developer to make a conscious decision to opt into mutation, which is almost always the right trade-off. The vast majority of data transformations in a well-structured application are pure: they take input, produce new output, and leave the original untouched.

We added readonly incrementally using a codemod that flagged every interface property without it. Within two months, ninety-one percent of our interfaces were fully immutable at the type level.

5. Result Types Instead of Thrown Exceptions

JavaScript's exception system is fundamentally untyped. When a function declares throws in languages like Java, the caller is forced to handle specific exception types. In TypeScript, a function that throws provides no type-level signal to its callers. The exception propagates invisibly until something catches it, or it crashes the process.

We introduced a Result type for all operations that can fail in expected ways:

type Result<T, E = string> =
  | { ok: true; value: T }
  | { ok: false; error: E };

async function transferFunds(
  from: AccountId,
  to: AccountId,
  amount: number
): Promise<Result<Transaction, TransferError>> {
  if (amount <= 0) {
    return { ok: false, error: "invalid_amount" };
  }
  // ...
  return { ok: true, value: transaction };
}

The caller must inspect the ok field before accessing the value. There is no way to forget. The error type is explicit, documented, and narrowable. We still use exceptions for truly unexpected failures, things like network timeouts or out-of-memory conditions that represent infrastructure problems rather than business logic. But for domain errors, validation failures, and expected edge cases, the Result type makes failure handling a first-class part of the function signature.

The Compound Effect

No single pattern was revolutionary. Discriminated unions, branded types, exhaustiveness checks, immutability conventions, and result types are all well-documented techniques that have existed in the TypeScript ecosystem for years. The impact came from adopting them together, consistently, across the entire codebase.

Each pattern closes a specific class of bug. Together, they create a system where the compiler catches errors that previously required runtime testing, code review vigilance, or production incident post-mortems to discover. The type system becomes a continuously running test suite that executes in milliseconds on every keystroke.

Six months in, our team spends less time debugging, less time writing defensive runtime checks, and less time waking up at three in the morning to a PagerDuty alert. The types do the work instead.


Daniel Okoro is a staff engineer based in Lagos and London who writes about TypeScript, distributed systems, and the operational side of software. He previously worked at Stripe and Vercel.