Skip to content

TypeScript Patterns That Actually Scale

Stephan Birkeland 16 min read Updated Feb 21, 2026
TypeScript Patterns That Actually Scale

I mass-produced enough any types in my early TypeScript days to fill a landfill. Then my codebase hit 50 files and suddenly TypeScript wasn’t helping me, it was just commenting on the wreckage. These are the patterns that pulled me out (and if you’re picking up TypeScript for the first time, my learn any tech stack fast framework applies here too). I reach for them in every project that’s outgrown the “single file of vibes” phase, and they’ve held up across React apps, Node backends, and full-stack monorepos.

Discriminated Unions Are Your State Machine’s Best Friend

If your app has a loading spinner, an error banner, and a success state, you have a state machine. You might be modeling it with a bag of optional fields and praying. That’s like using a grocery list as a contract. Technically words on paper, legally meaningless.

Discriminated unions fix this:

// The "bag of maybes" approach — invites impossible states
type User = {
  status: string;
  name?: string;
  error?: string;
  loadedAt?: Date;
};

// Discriminated unions — each state is explicit
type User =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "loaded"; name: string; loadedAt: Date }
  | { status: "error"; error: string };

Now TypeScript actually earns its keep. It forces you to handle every case, and it narrows the type in each branch:

function renderUser(user: User) {
  switch (user.status) {
    case "idle":
      return "Click to load user";
    case "loading":
      return "Loading...";
    case "loaded":
      // TypeScript knows `name` and `loadedAt` exist here
      return `${user.name} (loaded ${user.loadedAt.toLocaleDateString()})`;
    case "error":
      // TypeScript knows `error` exists here
      return `Failed: ${user.error}`;
  }
}

You know those toys where you push the shaped blocks through the matching holes? Square block goes in the square hole, star goes in the star hole, and the triangle absolutely does NOT fit in the circle hole no matter how hard you shove it. (I have tried.)

That’s discriminated unions. Instead of having one giant toy box where every piece is jumbled together and you’re never sure which piece you’re holding, you sort them into labeled bins: “loading stuff goes here,” “finished stuff goes here,” “broken stuff goes here.” Each bin only has the pieces that belong in it. Now when you grab from the “finished” bin, you KNOW the name and the date are in there because that bin requires them. TypeScript won’t let you grab from the wrong bin. I mass-produced bugs for months before learning this, and honestly I’m still a little mad nobody told me sooner.

I use this for API responses, form states, modal states, WebSocket message types, basically anything where an entity can be in distinct modes. Once you start seeing discriminated unions everywhere, you can’t unsee them. That’s either a superpower or a curse. Possibly both.

Branded Types Will Stop You Mixing Up Your Strings

Here’s a fun one. TypeScript will happily let you pass a userId where an orderId is expected, because they’re both string. It’s like a librarian who files books alphabetically by color. Technically organized, functionally useless.

Branded types add a phantom tag to primitives so the compiler can tell them apart:

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

type UserId = Brand<string, "UserId">;
type OrderId = Brand<string, "OrderId">;
type Email = Brand<string, "Email">;

// Constructor functions with validation
function UserId(id: string): UserId {
  if (!id.startsWith("usr_")) throw new Error("Invalid user ID");
  return id as UserId;
}

function Email(email: string): Email {
  if (!email.includes("@")) throw new Error("Invalid email");
  return email as Email;
}

Now swapping arguments is a compile-time error instead of a 2 AM production incident:

function getOrder(userId: UserId, orderId: OrderId) {
  // ...
}

const uid = UserId("usr_abc123");
const oid = "ord_xyz789" as OrderId;

getOrder(uid, oid);   // OK
getOrder(oid, uid);   // Error: Argument of type 'OrderId' is not assignable to 'UserId'

Imagine you have a red crayon and a blue crayon, but someone peeled the labels off both of them. They’re both just “crayon” now. You grab what you think is the red one, start coloring your fire truck, and… it’s blue. Your fire truck is blue. Nobody stopped you because a crayon is a crayon.

That’s what happens with IDs in TypeScript. A user ID and an order ID are both just text. TypeScript can’t tell them apart, so if you swap them by accident, nobody notices until something weird breaks. Branded types are like putting the labels back on your crayons. Red crayon goes in the red slot, blue crayon goes in the blue slot. Try to put the blue one in the red slot and TypeScript yells “WRONG CRAYON” before you ruin any fire trucks. I wish I’d known this before I spent a whole evening debugging a mixed-up ID issue that turned out to be two arguments in the wrong order.

Const Assertions Freeze Your Literals

By default, TypeScript widens your constants into general types. Define timeout: 5000 and TypeScript sees number. Thanks for nothing.

The as const assertion locks everything down to exact literal types, deeply readonly:

// Without as const — types are widened
const CONFIG = {
  api: "https://api.example.com",
  timeout: 5000,
  retries: 3,
};
// Type: { api: string; timeout: number; retries: number }

// With as const — exact literal types, deeply readonly
const CONFIG = {
  api: "https://api.example.com",
  timeout: 5000,
  retries: 3,
} as const;
// Type: { readonly api: "https://api.example.com"; readonly timeout: 5000; readonly retries: 3 }

Where this really pays off is deriving union types from runtime values, single source of truth, no duplication:

const ROLES = ["admin", "editor", "viewer"] as const;
type Role = (typeof ROLES)[number]; // "admin" | "editor" | "viewer"

const STATUS_MAP = {
  active: { label: "Active", color: "green" },
  inactive: { label: "Inactive", color: "gray" },
  banned: { label: "Banned", color: "red" },
} as const;

type Status = keyof typeof STATUS_MAP; // "active" | "inactive" | "banned"

Add a new role to the array and the Role type updates automatically. Remove one and every usage lights up red. It’s the kind of thing that makes you wonder why you ever maintained types and values separately.

You tell TypeScript “the timeout is 5000.” TypeScript goes “cool, it’s a number” and immediately forgets it was specifically 5000. Thanks for nothing, TypeScript.

as const is like writing in permanent marker instead of pencil. It tells TypeScript “this is exactly 5000, not just ‘some number,’ and it’s never changing.” The really cool part: if you make a list like ["admin", "editor", "viewer"] as const, TypeScript automatically knows those are the only three options. Add a fourth to the list and the type updates by itself. It’s like writing the names of everyone allowed in your treehouse on a magic whiteboard that automatically updates the “who’s allowed” sign on the door. I don’t know why I ever kept separate lists for values and types. That’s just asking for them to get out of sync, and they always did.

The satisfies Operator Lets You Have Your Cake and Eat It

I genuinely think satisfies is the most underused feature in modern TypeScript. It solves the ancient tension between “I want the compiler to validate this” and “I want to keep my nice narrow types.” Before satisfies, you had to pick one.

type Theme = {
  colors: Record<string, string>;
  spacing: Record<string, number>;
};

// With a type annotation — validates, but you lose specificity
const theme: Theme = {
  colors: { primary: "#d4a96a", bg: "#1a1209" },
  spacing: { sm: 4, md: 8, lg: 16 },
};
theme.colors.primary; // string (we lost the literal)
theme.colors.oops;    // string (no error — Record<string, string> allows any key)

// With satisfies — validates AND preserves narrow types
const theme = {
  colors: { primary: "#d4a96a", bg: "#1a1209" },
  spacing: { sm: 4, md: 8, lg: 16 },
} satisfies Theme;
theme.colors.primary; // "#d4a96a" (literal preserved)
theme.colors.oops;    // Error: Property 'oops' does not exist

I reach for satisfies on config objects, route definitions, and anywhere I want validation without surrendering type precision:

type RouteConfig = {
  path: string;
  auth: boolean;
  roles?: readonly string[];
};

const routes = {
  home: { path: "/", auth: false },
  dashboard: { path: "/dashboard", auth: true, roles: ["admin", "editor"] as const },
  settings: { path: "/settings", auth: true },
} satisfies Record<string, RouteConfig>;

// Full autocompletion on routes.dashboard.roles
// TypeScript knows it's readonly ["admin", "editor"], not string[] | undefined

Normally you have to pick: either the teacher checks your homework but forgets your exact answers, or the teacher remembers your exact answers but doesn’t check anything. You can get graded OR get remembered. Not both. Pick one. Thanks, I hate it.

satisfies is the teacher who does both. Checks that your coloring stays inside the lines AND remembers exactly which colors you used. Before satisfies existed, I had to choose between TypeScript catching my mistakes and TypeScript remembering what I actually wrote. Now I get both, and I use it on basically every config object and settings file. It’s the TypeScript feature I wish I’d had from the start.

Template Literal Types Are Regex at Compile Time (Sort Of)

Template literal types let you enforce string patterns without a runtime regex. It’s like having a bouncer at the door of your function signatures:

type HttpMethod = "GET" | "POST" | "PUT" | "DELETE";
type ApiPath = `/${string}`;
type Endpoint = `${HttpMethod} ${ApiPath}`;

function registerRoute(endpoint: Endpoint, handler: () => void) {
  // ...
}

registerRoute("GET /users", listUsers);       // OK
registerRoute("POST /users", createUser);      // OK
registerRoute("PATCH /users", updateUser);     // Error: not a valid HttpMethod
registerRoute("GET users", listUsers);         // Error: path must start with /

Combine them with mapped types and things get interesting:

type EventMap = {
  click: { x: number; y: number };
  keydown: { key: string; code: string };
  scroll: { offset: number };
};

type EventHandler<T extends keyof EventMap> = (event: EventMap[T]) => void;

type PrefixedHandlers = {
  [K in keyof EventMap as `on${Capitalize<K>}`]: EventHandler<K>;
};

// PrefixedHandlers = {
//   onClick: (event: { x: number; y: number }) => void;
//   onKeydown: (event: { key: string; code: string }) => void;
//   onScroll: (event: { offset: number }) => void;
// }

You know how Mad Libs work? “I went to the [PLACE] and ate a [FOOD].” The blanks have rules. You can’t put a food in the place slot. Template literal types are Mad Libs for your code. You define the blanks and the rules (“the first word must be GET, POST, PUT, or DELETE, then a space, then something starting with /”) and TypeScript makes sure nobody fills in the blanks wrong. Write "PATCH /users" when PATCH isn’t in the allowed words? TypeScript catches it before your code even runs. It’s like having a really strict Mad Libs partner who won’t let you put “pizza” in the verb slot. Annoying in the game, incredibly useful in code.

Utility Type Recipes

TypeScript ships decent utility types out of the box, but the real leverage comes from composing them. Here are three I’ve copy-pasted into enough projects that they deserve a permanent home:

// Make specific fields required (rest stay as-is)
type RequireFields<T, K extends keyof T> = T & Required<Pick<T, K>>;

type User = {
  id: string;
  name?: string;
  email?: string;
  avatar?: string;
};

type UserWithEmail = RequireFields<User, "email">;
// { id: string; name?: string; email: string; avatar?: string }
// Deep partial — makes every nested field optional
type DeepPartial<T> = {
  [P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P];
};

type Settings = {
  display: { theme: string; fontSize: number };
  notifications: { email: boolean; push: boolean };
};

type SettingsUpdate = DeepPartial<Settings>;
// Can pass { display: { fontSize: 14 } } without specifying theme
// Extract function parameter types as a named tuple
type Params<T extends (...args: any[]) => any> = T extends (...args: infer P) => any ? P : never;

function createUser(name: string, email: string, role: "admin" | "user") {
  // ...
}

type CreateUserParams = Params<typeof createUser>;
// [name: string, email: string, role: "admin" | "user"]

These are like LEGO instruction booklets I keep photocopying into every new project. Three recipes I use all the time:

// "Hey TypeScript, this form is mostly optional BUT
// the email field is REQUIRED. Everything else can stay chill."
type RequireFields<T, K extends keyof T> = T & Required<Pick<T, K>>;

type User = {
  id: string;
  name?: string;     // optional, whatever
  email?: string;    // optional... until we say otherwise
  avatar?: string;   // optional, no pressure
};

// NOW email is required. The rest stay optional.
type UserWithEmail = RequireFields<User, "email">;
// "Make EVERYTHING optional, even the stuff inside the stuff."
// Like telling your kid "you don't HAVE to clean ANY part of your room"
type DeepPartial<T> = {
  [P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P];
};

type Settings = {
  display: { theme: string; fontSize: number };
  notifications: { email: boolean; push: boolean };
};

// Now you can update just the font size without touching anything else
type SettingsUpdate = DeepPartial<Settings>;
// "What ingredients does this recipe need?"
// Peeks at a function and tells you what it expects
type Params<T extends (...args: any[]) => any> = T extends (...args: infer P) => any ? P : never;

function createUser(name: string, email: string, role: "admin" | "user") {
  // ...
}

// Now I know EXACTLY what createUser wants without reading its code
type CreateUserParams = Params<typeof createUser>;

The Builder Pattern with Method Chaining

When constructing complex objects, a typed builder gives you autocompletion, validation, and the satisfying feeling of chaining methods like train cars clicking together:

type QueryConfig = {
  table: string;
  columns: string[];
  where?: string;
  orderBy?: string;
  limit?: number;
};

class QueryBuilder<T extends Partial<QueryConfig> = {}> {
  private config: T;

  constructor(config: T = {} as T) {
    this.config = config;
  }

  from<Table extends string>(table: Table) {
    return new QueryBuilder({ ...this.config, table });
  }

  select<Cols extends string[]>(...columns: Cols) {
    return new QueryBuilder({ ...this.config, columns });
  }

  where(condition: string) {
    return new QueryBuilder({ ...this.config, where: condition });
  }

  limit(n: number) {
    return new QueryBuilder({ ...this.config, limit: n });
  }

  build(this: QueryBuilder<QueryConfig>): QueryConfig {
    return this.config;
  }
}

const query = new QueryBuilder()
  .from("users")
  .select("id", "name", "email")
  .where("active = true")
  .limit(10)
  .build(); // Only callable when all required fields are set

The trick here is the this parameter on build(). It won’t compile unless you’ve set table and columns. Try calling .build() without .from() and TypeScript slaps your hand. Beautiful.

Imagine ordering a pizza by clicking buttons in order: pick the size, pick the toppings, pick the crust, then hit “order.” You can’t hit “order” until you’ve picked a size and at least one topping. The builder pattern works the same way for building stuff in code. Each step snaps onto the last like LEGO bricks, and the “done” button only appears when you’ve filled in all the required pieces.

// The order form: what does a complete pizza order look like?
type QueryConfig = {
  table: string;       // Required: which table (which pizza size)
  columns: string[];   // Required: which columns (which toppings)
  where?: string;      // Optional: filter (hold the anchovies)
  orderBy?: string;    // Optional: sorting (arrange by deliciousness)
  limit?: number;      // Optional: how many (just 10 slices please)
};

class QueryBuilder<T extends Partial<QueryConfig> = {}> {
  private config: T;

  constructor(config: T = {} as T) {
    this.config = config;
  }

  // Step 1: pick your table (pick your size)
  from<Table extends string>(table: Table) {
    return new QueryBuilder({ ...this.config, table });
  }

  // Step 2: pick your columns (pick your toppings)
  select<Cols extends string[]>(...columns: Cols) {
    return new QueryBuilder({ ...this.config, columns });
  }

  // Optional: add a filter
  where(condition: string) {
    return new QueryBuilder({ ...this.config, where: condition });
  }

  // Optional: set a limit
  limit(n: number) {
    return new QueryBuilder({ ...this.config, limit: n });
  }

  // The "order" button. Only works when table + columns are set!
  build(this: QueryBuilder<QueryConfig>): QueryConfig {
    return this.config;
  }
}

// Click click click click... order!
const query = new QueryBuilder()
  .from("users")                  // Pick the table
  .select("id", "name", "email")  // Pick the columns
  .where("active = true")         // Optional filter
  .limit(10)                      // Optional limit
  .build();                       // Hit "order" (only works because we did the required steps)

Try calling .build() without .from() and TypeScript goes “nope, you forgot to pick a size.” I find this pattern deeply satisfying in a way that probably says something unflattering about my personality.

When to Reach for What

These patterns are tools, not commandments. Here’s my mental shortcut:

PatternUse When
Discriminated unionsModeling states, API responses, message types
Branded typesIDs, monetary values, validated strings
as constConfig objects, enum-like arrays, lookup tables
satisfiesAny config or constant that needs both validation and inference
Template literalsPublic API string parameters, event names, route patterns
Utility typesDRY-ing up type definitions, API request/response types

The goal isn’t to flex every advanced feature TypeScript offers. It’s to make impossible states impossible and let the compiler catch mistakes before your users do. Start with discriminated unions and satisfies. They deliver the biggest payoff with the least brain damage. Layer on the rest as your codebase demands it. Having the right editor tooling helps too. I cover TypeScript-specific extensions in my VS Code extensions guide.

You absolutely don’t need all of these on day one. That would be like opening the whole crayon box and trying to use every color at once. Your drawing would be a mess. Start with discriminated unions (the labeled toy bins, biggest bang for your buck). Then try satisfies when you have config objects. Then as const when you’re tired of keeping two lists that are supposed to match. The rest comes naturally when your project gets big enough that you keep mixing things up. The whole point is making the computer catch your mistakes instead of your users catching them for you. I learned every one of these patterns the hard way, by shipping the bug first.


These patterns have caught more bugs for me than my test suite, which is either a compliment to the patterns or an indictment of my tests. What TypeScript patterns do you keep coming back to?

Share:
Stephan Birkeland

Stephan Birkeland

Consultant and developer based in Norway. Runs on coffee, curiosity, and a questionable number of side projects.

Keep reading

Up nextdeveloper-tools

VS Code Extensions Every Developer Needs in 2026