Skip to content
Back to Blog
Alex CarterAlex Carter1 min read

TypeScript Patterns I Actually Use in Production

Beyond the basics: discriminated unions, branded types, and the patterns that have earned a permanent place in my toolbox.

TypeScript Patterns I Actually Use in Production

TypeScript has a reputation for being verbose, but most of the noise comes from patterns that aren't actually that useful. Here are the ones I reach for daily — the ones that pay for themselves.

Discriminated Unions

If you take one thing from this post, take this. Discriminated unions model state that can be in one of several shapes, and the compiler narrows it for you.

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

Because every variant has a literal status, TypeScript narrows automatically:

function render<T>(state: RequestState<T>) {
  switch (state.status) {
    case "success":
      return state.data; // T — narrowed correctly
    case "error":
      return state.error.message; // Error — narrowed correctly
    default:
      return null;
  }
}

Why it matters

Exhaustive switch checks mean the compiler errors if you add a new variant and forget to handle it. That's refactoring safety for free.

Branded Types

Sometimes two types are structurally identical but semantically different — a UserId and a PostId are both strings, but mixing them up is a bug.

type Brand<T, B> = T & { readonly __brand: B };
 
type UserId = Brand<string, "UserId">;
type PostId = Brand<string, "PostId">;
 
function getUser(id: UserId) { /* ... */ }
 
const post: PostId = "abc" as PostId;
// getUser(post); // ❌ Type error — exactly what we want

The satisfies Operator

as const is too strict; : Type is too loose. satisfies is just right.

const config = {
  port: 3000,
  host: "localhost",
} satisfies ServerConfig;
// config.port is still `number`, not `3000`

Generic Constraints Done Right

Avoid any. Use bounded generics to express intent.

function pick<T, K extends keyof T>(obj: T, keys: K[]): Pick<T, K> {
  return keys.reduce((acc, key) => {
    acc[key] = obj[key];
    return acc;
  }, {} as Pick<T, K>);
}

Summary

  • Discriminated unions for state machines.
  • Branded types to stop accidental mix-ups.
  • satisfies when you want validation without losing narrow types.

These patterns keep types precise without getting in the way. Add them one at a time — you'll feel the difference.


Share:XTelegramLinkedInvia @Ariyan

// Comments

Powered by Giscus

Comments are powered by GitHub Discussions. Configure Giscus incomponents/blog/comments.tsxto enable live discussions.

// Related Posts