Skip to content
TypeScript ts best-practices 4 min read

Maximizing Type Safety

Turning on strict is the baseline; maximizing type safety means using the type system aggressively to make illegal states unrepresentable and to catch mistakes the moment they appear. TypeScript’s structural typing is convenient but sometimes too permissive — a userId string and an orderId string are interchangeable unless you say otherwise. This page covers four techniques that push safety further: branded (nominal) types to distinguish look-alike values, exhaustiveness checking to force every case to be handled, readonly to prevent accidental mutation, and the satisfies operator to validate without widening. Together they convert whole classes of runtime bugs into compile errors.

Branded (nominal) types

TypeScript is structural: any two strings are assignable to each other. Branding adds a phantom property to create nominal distinctions, so a UserId can never be passed where an OrderId is expected.

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

const asUserId = (id: string): UserId => id as UserId;

function loadUser(id: UserId) {/* ... */}

const uid = asUserId("u_1");
loadUser(uid); // ✅

// ❌ Error: 'string' is not assignable to parameter of type 'UserId'.
loadUser("u_1");

The __brand property exists only in the type system — there is zero runtime cost — yet it makes mixing up identifiers, currencies, or unvalidated strings a compile error. Use a small constructor function (asUserId) as the single, auditable place a brand is applied.

Exhaustiveness checking with never

When you handle a union with a switch, you want a compile error if someone later adds a new variant. Assigning the unhandled value to never in the default branch gives you exactly that.

type Shape =
  | { kind: "circle"; r: number }
  | { kind: "square"; size: number };

function area(shape: Shape): number {
  switch (shape.kind) {
    case "circle": return Math.PI * shape.r ** 2;
    case "square": return shape.size ** 2;
    default: {
      const _exhaustive: never = shape; // ❌ Error if a new kind is added
      return _exhaustive;
    }
  }
}

If a { kind: "triangle" } member is later added to Shape, shape is no longer never in default, and the assignment fails to compile — pointing you straight at the unhandled case. This turns “I forgot to update the switch” into a guaranteed build failure.

readonly for immutability

Marking data readonly prevents accidental mutation and documents intent. Use readonly on properties and arrays, and as const to freeze literals into their narrowest, deeply immutable form.

interface Config {
  readonly host: string;
  readonly ports: readonly number[];
}

const cfg: Config = { host: "localhost", ports: [80, 443] };
// ❌ Error: Cannot assign to 'host' because it is a read-only property.
cfg.host = "remote";
// ❌ Error: Property 'push' does not exist on type 'readonly number[]'.
cfg.ports.push(8080);

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

readonly is a compile-time guard with no runtime overhead. The as const assertion both freezes the array and lets you derive a precise union type from its values, keeping a single source of truth.

The satisfies operator

satisfies checks that a value conforms to a type without widening it to that type, so you keep the literal, inferred type for downstream use. It is ideal for config objects and lookup tables.

type Routes = Record<string, { method: "GET" | "POST" }>;

const routes = {
  list: { method: "GET" },
  create: { method: "POST" },
} satisfies Routes;

routes.list.method; // type is "GET", not the wider "GET" | "POST"
// ❌ Error caught by satisfies: method must be "GET" | "POST"
// const bad = { x: { method: "PUT" } } satisfies Routes;

A plain annotation const routes: Routes would validate the object but erase the specific "GET" literal, leaving routes.list.method as "GET" | "POST". satisfies gives you validation and the narrow inferred type at once.

Comparing the techniques

TechniqueCatchesRuntime cost
Branded typesMixing look-alike valuesNone
Exhaustiveness (never)Unhandled union variantsNone
readonly / as constAccidental mutationNone
satisfiesInvalid config, lost literalsNone

Every technique here is purely compile-time — they erase completely at build. You pay nothing at runtime for catching these bugs before your code ever ships.

Use these tools to make invalid states impossible to express. The more your types reject, the fewer assertions and runtime checks you need, and the more confidently you can refactor.

Best Practices

  • Brand identifiers, money, and unvalidated input so look-alike values can’t be swapped.
  • Add a never exhaustiveness check to every switch over a discriminated union.
  • Default to readonly properties and arrays; mutate only where you deliberately must.
  • Use as const for literal data and derive union types from it instead of duplicating.
  • Prefer satisfies over annotations when you want validation without losing literal types.
  • Model domain rules in types so the compiler, not runtime checks, enforces them.
  • Combine with strict and noUncheckedIndexedAccess for the strongest baseline.
Last updated June 29, 2026
Was this helpful?