All posts
TypeScriptEngineeringPatterns

TypeScript Patterns I Actually Use in Production

TypeScript can be used superficially or deeply. These are the patterns that have genuinely improved the quality and maintainability of production codebases I've worked on.

2 September 2025·3 min read

TypeScript is only as good as your use of it. Slapping : any everywhere gives you the syntax without the safety. These are the patterns I return to consistently because they actually make code easier to reason about, refactor, and hand to another engineer.

1. Discriminated Unions over optional fields

The instinct when modelling state is to reach for optional fields:

interface Request {
  status: "idle" | "loading" | "success" | "error";
  data?: User;
  error?: string;
}

The problem: TypeScript can't help you here. When status === "success", data should always be defined — but nothing enforces that. You'll end up writing defensive checks that litter your code.

Discriminated unions solve this:

type Request =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "success"; data: User }
  | { status: "error"; error: string };

Now when you switch on status, TypeScript narrows the type for you. The success branch knows data exists. No defensive guards, no runtime surprises.

2. satisfies for config objects

The satisfies operator (introduced in 4.9) is underused. It lets you validate a value against a type without widening it:

const routes = {
  home: "/",
  blog: "/blog",
  contact: "/contact",
} satisfies Record<string, string>;

// routes.home is inferred as "/" not string
type HomeRoute = typeof routes.home; // "/"

Without satisfies, using a type annotation loses the literal types. With it, you get both validation and narrowing. Excellent for configuration maps, route definitions, and lookup tables.

3. Template literal types for tight APIs

When you have a set of known string patterns, don't reach for string — model it precisely:

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

type RouteKey = `${HttpMethod} ${ApiPath}`;
// "GET /api/users", "POST /api/orders", etc.

This is particularly powerful when combined with mapped types to build typed API clients or router definitions.

4. Opaque types to prevent primitive confusion

When you have multiple string or number values representing different domains, TypeScript won't stop you passing a UserId where an OrderId is expected. Branded (opaque) types fix this:

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

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

function getUser(id: UserId): User {
  /* ... */
}

const rawId = "abc-123";
getUser(rawId); // ✗ — string is not UserId
getUser(rawId as UserId); // ✓ — explicit cast required at the boundary

The cost is one cast at the domain boundary (typically in a factory or repository). The benefit is that cross-domain ID confusion becomes a compile-time error rather than a runtime bug in production.

5. infer in conditional types for utility extraction

Once you understand infer, a lot of painful type gymnastics disappear:

type Awaited<T> = T extends Promise<infer U> ? U : T;
type ReturnType<T> = T extends (...args: unknown[]) => infer R ? R : never;
type FirstArg<T> = T extends (first: infer A, ...rest: unknown[]) => unknown
  ? A
  : never;

infer says: "capture whatever type is in this structural position." This is how you write utilities that adapt to the types they receive rather than requiring explicit generic parameters at every call site.


None of these patterns require exotic library dependencies. They're part of the language, and they pay dividends at the scale where TypeScript's value is greatest: large codebases, multiple contributors, and fast-moving product requirements.

The common thread is using the type system to model your domain precisely, not just to annotate your code.