Back to journal
Engineering

The TypeScript Patterns That Changed How Our Team Writes Code

Discriminated unions, branded types, const assertions, and satisfies — these TypeScript patterns eliminate entire categories of bugs before your code even runs.

PMML Engineering · Studio 2 June 2026 8 min read 0 views
The TypeScript Patterns That Changed How Our Team Writes Code

After shipping TypeScript across 40+ production projects, these are the patterns our team uses daily. Not academic type theory — practical patterns that catch real bugs.

1. Discriminated Unions > type Field + Casting

Code editor with TypeScript

// ❌ The "any shape" approach
interface Notification {
  type: string;
  title: string;
  message?: string;
  amount?: number;
  url?: string;
}

// ✅ Discriminated union — each variant is fully typed
type Notification =
  | { type: "info"; title: string; message: string }
  | { type: "payment"; title: string; amount: number; currency: string }
  | { type: "link"; title: string; url: string; label: string };

function render(n: Notification) {
  switch (n.type) {
    case "info":
      return `📝 ${n.title}: ${n.message}`;
    case "payment":
      return `💰 ${n.title}: ${n.amount} ${n.currency}`;
    case "link":
      return `🔗 ${n.title}: <a href="${n.url}">${n.label}</a>`;
    // TypeScript will error if you miss a case! ✅
  }
}

The compiler enforces exhaustive handling. Add a new notification type? TypeScript flags every switch statement that needs updating.

2. Branded Types — Make Invalid States Impossible

// Without branded types: easy to mix up IDs
function transferMoney(from: string, to: string, amount: number) { ... }
transferMoney(accountId, recipientId, 100); // ✅
transferMoney(recipientId, accountId, 100); // ⚠️ Swapped! No error!

// With branded types:
type AccountId = string & { readonly __brand: "AccountId" };
type RecipientId = string & { readonly __brand: "RecipientId" };

function accountId(id: string): AccountId {
  return id as AccountId;
}

function transferMoney(from: AccountId, to: RecipientId, amount: number) { ... }

const myAccount = accountId("acc_123");
const recipient = recipientId("rcp_456");

transferMoney(myAccount, recipient, 100);   // ✅
transferMoney(recipient, myAccount, 100);   // ❌ Type error!

We use branded types for all domain IDs (UserId, OrderId, ProductId). Eliminates an entire class of parameter-ordering bugs.

3. satisfies — Type-Check Without Widening

// ❌ Type annotation widens the type
const routes: Record<string, { path: string; auth: boolean }> = {
  home: { path: "/", auth: false },
  dashboard: { path: "/dashboard", auth: true },
};
routes.typo; // No error! Record<string, ...> allows any key 😱

// ✅ satisfies validates without widening
const routes = {
  home: { path: "/", auth: false },
  dashboard: { path: "/dashboard", auth: true },
} satisfies Record<string, { path: string; auth: boolean }>;

routes.typo;       // ❌ Error: Property 'typo' does not exist
routes.home.path;  // ✅ Type is "/" (literal), not string

Abstract type system visualization

4. Const Assertions for Immutable Configs

// Without as const — types are widened
const PLANS = {
  free: { price: 0, features: ["basic"] },
  pro: { price: 29, features: ["basic", "advanced", "support"] },
};
// Type: { free: { price: number; features: string[] }; ... }

// With as const — types are narrow and readonly
const PLANS = {
  free: { price: 0, features: ["basic"] },
  pro: { price: 29, features: ["basic", "advanced", "support"] },
} as const;
// Type: { readonly free: { readonly price: 0; readonly features: readonly ["basic"] }; ... }

type PlanName = keyof typeof PLANS; // "free" | "pro"
type ProFeature = typeof PLANS.pro.features[number]; // "basic" | "advanced" | "support"

5. Template Literal Types for API Routes

type ApiVersion = "v1" | "v2";
type Resource = "users" | "posts" | "comments";
type ApiRoute = `/api/${ApiVersion}/${Resource}`;

// Only valid routes are allowed:
const route: ApiRoute = "/api/v1/users";    // ✅
const bad: ApiRoute = "/api/v3/users";      // ❌ Error!
const worse: ApiRoute = "/api/v1/orders";   // ❌ Error!

// Combine with mapped types for route handlers
type RouteHandlers = {
  [R in ApiRoute]: (req: Request) => Response;
};

6. The infer Keyword — Extract Types from Other Types

// Extract the return type of an async function
type AsyncReturnType<T> = T extends (...args: unknown[]) => Promise<infer R>
  ? R
  : never;

// Extract the element type of an array
type ElementOf<T> = T extends (infer E)[] ? E : never;

// Real usage: Extract Supabase row types
type Tables = Database["public"]["Tables"];
type UserRow = Tables["users"]["Row"];
type PostInsert = Tables["posts"]["Insert"];

Developer working with types

Our Team Rules

  1. Never use any — use unknown and narrow
  2. Never use as type assertions — use type guards instead
  3. Always use strict mode"strict": true in tsconfig
  4. Discriminated unions for anything with variants
  5. Branded types for domain IDs
  6. satisfies over type annotations when you want autocomplete

These patterns aren't about type gymnastics — they're about making impossible states truly impossible.

Which pattern is new to you? Bookmark this and share it with your team.

#typescript#patterns#best-practices#engineering

Keep reading

You might also like