← Blog
·10 blog.minutes

Modern TypeScript: Patterns and Practices for 2026

From strict configuration to type-safe API clients — the TypeScript patterns that separate production-grade code from toy projects in 2026.

TypeScript has evolved faster than most ecosystems can keep up with. Every release brings new syntax, stricter checks, and patterns that rewrite what feels idiomatic. The difference between TypeScript code that merely compiles and code that genuinely leverages the type system is vast — and that gap determines whether your types are documentation or noise.

This article covers the patterns that matter most for production TypeScript in 2026. These are not academic exercises. They are the practices that make large codebases safer, APIs harder to misuse, and refactoring less terrifying. Every example comes from real patterns used in production systems handling millions of requests.

The Foundation: TypeScript Configuration for 2026

The most impactful decision you make about TypeScript quality is not in your code — it is in your tsconfig.json. The baseline has moved past strict: true. In 2026, a production-grade configuration must enable checks that were opt-in or experimental in earlier versions.

// tsconfig.json — the 2026 baseline
{
  "compilerOptions": {
    "strict": true,
    "exactOptionalPropertyTypes": true,
    "noUncheckedIndexedAccess": true,
    "noPropertyAccessFromIndexSignature": true,
    "verbatimModuleSyntax": true,
    "isolatedModules": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true
  }
}

Each flag eliminates a class of bugs. exactOptionalPropertyTypes prevents the common mistake where ?: string | undefined is assigned undefined explicitly — the distinction between missing and present-but-undefined matters in API boundaries. noUncheckedIndexedAccess forces you to handle undefined for every object access with a dynamic key, which catches runtime crashes before they ship. verbatimModuleSyntax ensures your module resolution matches the runtime, eliminating the silent mismatches that break ESM projects in production.

Teams that adopt this configuration report significantly fewer production incidents related to null references and undefined properties. The overhead of handling those extra undefined checks upfront is trivial compared to debugging a crash because an API response was missing a field you assumed existed.

Discriminated Unions — Your Most Powerful Pattern

Discriminated unions are the single most impactful pattern in TypeScript. They model states explicitly, make illegal states unrepresentable, and give the compiler the information it needs to enforce exhaustive handling. If you are not using them for any data that has multiple shapes, you are fighting the type system instead of letting it work for you.

type ApiState<S, E = Error> =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "success"; data: S }
  | { status: "error"; error: E };

// Exhaustive match — if you add a status, this breaks at compile time
function renderState<S>(state: ApiState<S>): string {
  switch (state.status) {
    case "idle":
      return "Awaiting input";
    case "loading":
      return "Loading...";
    case "success":
      return `Got ${JSON.stringify(state.data)}`;
    case "error":
      return `Failed: ${state.error.message}`;
  }
}

// Usage — impossible to access data on a loading state
const userState: ApiState<User> = { status: "loading" };
// userState.data — does not compile
// userState.status — narrows correctly after any check

The magic is in the narrowing. When you check state.status === "success", TypeScript knows exactly which variant you are in and provides the correct type for every field on that branch. This eliminates entire categories of defensive checks that runtime code would otherwise need. The compiler becomes your test suite for invalid state transitions.

For this pattern to work effectively, the discriminant property (status in the example above) must be a literal type, not a general string. Each variant must have a unique literal value. The compiler uses that literal to discriminate between branches, and it will warn you if two variants have the same discriminant value.

  • Always use a literal string or number as the discriminant — never a generic string type.
  • Keep the shared properties minimal. Everything that differs between variants belongs inside the variant object.
  • Combine with the never type for exhaustiveness checks: declare a variable of type never in the default branch of a switch to catch unhandled cases at compile time.

Template Literal Types and Branded Types

Template literal types and branded types solve two distinct problems that regular TypeScript types cannot address. Template literal types give you string validation at the type level. Branded types give you nominal typing in a structurally typed world — they let you distinguish between two values that have the same shape but different semantic meanings.

// Template literal type — valid API routes are checked at compile time
type ApiRoute = `/api/${string}`;
type UserRoute = `/api/users/${string}`;

function fetchApi<T>(route: ApiRoute): Promise<T> {
  return fetch(route).then((r) => r.json());
}

// fetchApi("/invalid");       // Error: not assignable
// fetchApi("/api/users/123"); // OK

// Branded type — distinguish IDs that are both strings
type UserId = string & { __brand: "UserId" };
type OrderId = string & { __brand: "OrderId" };

function createUserId(id: string): UserId {
  return id as UserId;
}

function getUser(id: UserId): Promise<User> {
  return db.users.find(id);
}

const orderId = "ord_123" as OrderId;
// getUser(orderId); // Error: Type 'OrderId' is not assignable to type 'UserId'

Template literal types shine in any system where strings follow a predictable format. API route builders, CSS class generators, i18n key matchers, and event name systems all benefit from compile-time validation of string values. The syntax is intuitive — you write a pattern with ${} placeholders, and TypeScript validates that actual values match that pattern.

Branded types solve a different problem. TypeScript is structurally typed, which means two types with identical shapes are interchangeable. This is usually a feature, but it becomes dangerous when you have IDs that are both strings but represent different entities. A UserId and an OrderId should not be interchangeable, even if both are strings. The intersection with { __brand: "X" } creates a phantom type that exists only at compile time — it has no runtime cost.

Branded types are the closest TypeScript gets to nominal typing without additional tooling. One intersection with a phantom property can prevent the class of bugs where the wrong ID is passed to the wrong function.

The satisfies Operator — Inference Without Sacrifice

Before satisfies, TypeScript developers faced a dilemma when defining constants that needed to match a type while preserving their literal values. You could either annotate the type and lose the narrow inference, or omit the annotation and lose the validation. The satisfies operator removes this trade-off entirely.

type ColorPalette = {
  primary: string;
  secondary: string;
  accent: string;
};

// Before satisfies — loses literal types
const paletteOld: ColorPalette = {
  primary: "#0f0f0f",  // type is string, not "#0f0f0f"
  secondary: "#ffffff",
  accent: "#0055ff",
};

// After satisfies — validates shape, keeps literals
const palette = {
  primary: "#0f0f0f",  // type is "#0f0f0f"
  secondary: "#ffffff",
  accent: "#0055ff",
} satisfies ColorPalette;

// palette.primary; // type is "#0f0f0f", not string

// Works with complex types too
type EventMap = {
  click: { x: number; y: number };
  focus: { target: string };
  input: { value: string };
};

const handlers = {
  click: (e: { x: number; y: number }) => console.log(e.x, e.y),
  focus: (e: { target: string }) => console.log(e.target),
} satisfies Partial<Record<keyof EventMap, Function>>;

// handlers.click — retains the parameter type from the implementation
// handlers.input — missing, but satisfies allows Partial
// Add input handler later without breaking anything

The satisfies operator is most valuable in configuration objects, event handlers, and mapping structures where you want type safety on the shape but need the narrowest possible types for the values. It replaces a constellation of workarounds — as const assertions combined with type annotations, redundant type casts, and separate validation functions — with a single keyword.

Generic Patterns and Type-Safe API Clients

Generics in TypeScript are easy to use for simple cases — Array<T>, Promise<T> — but they become truly powerful when you combine constraints, conditional types, and inference in a single function signature. The most practical application of advanced generics is building type-safe API clients that eliminate entire categories of runtime errors.

// Type-safe API client
import { z } from "zod";

// Infer the output type from a Zod schema
type InferSchema<T extends z.ZodTypeAny> = T["_output"];

// Route definitions with path params and response schema
type RouteDef = {
  path: string;
  method: "GET" | "POST" | "PUT" | "DELETE";
  params?: Record<string, string>;
  body?: unknown;
};

class ApiClient {
  constructor(private base: string) {}

  get<
    TPath extends string,
    TSchema extends z.ZodTypeAny,
  >(
    path: TPath,
    schema: TSchema,
    ...[params]: TPath extends `${string}:${infer _}/${string}`
      ? [Record<string, string>]
      : TPath extends `${string}:${infer _}`
        ? [Record<string, string>]
        : [Record<string, string>?]
  ): Promise<InferSchema<TSchema>> {
    const resolved = params
      ? Object.entries(params).reduce(
          (p, [k, v]) => p.replace(`:${k}`, v),
          path
        )
      : path;
    return fetch(`${this.base}${resolved}`)
      .then((r) => r.json())
      .then((d) => schema.parse(d) as InferSchema<TSchema>);
  }
}

const api = new ApiClient("https://api.example.com");

const userSchema = z.object({
  id: z.string(),
  name: z.string(),
  email: z.string().email(),
});

// api.get("/users/:id", userSchema, { id: "123" });
// Result type: { id: string; name: string; email: string }
// If params are required but missing, type error at compile time

The ApiClient pattern combines several advanced techniques. Conditional types with infer detect whether the route contains path parameters and conditionally require the params argument. The response schema is parsed at runtime with Zod and inferred at compile time, so the return type is always correct. If the API changes its response shape, you update the schema and the compiler finds every consumer that breaks.

This pattern scales to hundreds of endpoints without ceremony. Each endpoint is just a path string and a Zod schema. The generic infrastructure handles the rest — path parameter resolution, response validation, and type inference. Teams using this pattern report eliminating the majority of integration bugs between frontend and backend.

  • Combine Zod or ArkType schemas with generic fetch wrappers for end-to-end type safety across the network boundary.
  • Use conditional types with infer to make arguments required or optional based on the route structure.
  • Return branded types from your API client so callers cannot accidentally swap IDs between different entity types.

Error Handling Patterns and Module Augmentation

Error handling is where most TypeScript codebases fall back to any and pretend the problem does not exist. The Result pattern — inspired by Rust and functional programming — brings errors into the type system so the compiler enforces that every error path is handled. Module augmentation extends this approach to third-party types, letting you add type safety to libraries that ship incomplete or overly loose type definitions.

// Result type — errors are part of the return type, not thrown

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

async function fetchUser(id: UserId): Promise<Result<User, ApiError>> {
  try {
    const res = await fetch(`/api/users/${id}`);
    if (!res.ok) {
      return { ok: false, error: await ApiError.fromResponse(res) };
    }
    return { ok: true, value: await res.json() };
  } catch (err) {
    return { ok: false, error: new ApiError("network", String(err)) };
  }
}

// Consumer must handle both branches — compiler enforces it
const result = await fetchUser(userId);
if (result.ok) {
  console.log(result.value.name); // result.value is User
} else {
  console.error(result.error.code); // result.error is ApiError
}

// --- Module augmentation for third-party types ---

// express-session types are incomplete — augment them
declare module "express-session" {
  interface SessionData {
    userId?: string;
    role: "admin" | "user" | "viewer";
    permissions: string[];
  }
}

// Now req.session.role narrows to "admin" | "user" | "viewer"
// Without augmentation, req.session.role would be any

The Result pattern forces explicit error handling at every call site. There is no way to silently ignore a failed operation — the compiler requires you to check result.ok before accessing result.value. This eliminates the forgotten try-catch that is the source of countless production incidents. The cost is a little more typing at each call site, but the benefit is that no error goes unhandled.

Module augmentation fills in the gaps where third-party type definitions fall short. Many popular libraries ship with overly permissive types — functions that return any, parameters typed as object, or missing properties on their interfaces. Instead of casting with as or using @ts-ignore, declare module "library-name" in a .d.ts or .ts file and add the missing types. The declarations merge automatically, and your entire codebase benefits from the fix.

The combination of these patterns — strict configuration, discriminated unions, template literal types, branded types, satisfies, type-safe generics, and explicit error handling — represents a comprehensive approach to TypeScript in 2026. Each pattern is independently useful, but together they create a codebase where the compiler catches mistakes that would otherwise become production incidents. The investment in learning these patterns pays for itself many times over in reduced debugging time, safer refactoring, and APIs that are genuinely hard to misuse.