Skip to content

TypeScript patterns I actually use

4 min read

No fluff. Just patterns that routinely pay off in real-world codebases—typesafe APIs, discriminated unions, exhaustive checks, and more.

Real collections reward patterns that keep types accurate with low ceremony. These are the ones I reach for again and again.

Discriminated unions + exhaustive checks

Model states with a discriminant and force the compiler to keep you honest as things evolve.

ts
type LoadState<T> =
  | { kind: "idle" }
  | { kind: "loading" }
  | { kind: "loaded"; data: T }
  | { kind: "error"; message: string };

function render(state: LoadState<string>): string {
  switch (state.kind) {
    case "idle":
      return "Idle";
    case "loading":
      return "Loading…";
    case "loaded":
      return `Data: ${state.data}`;
    case "error":
      return `Error: ${state.message}`;
    default:
      return assertNever(state); // 🔒 Exhaustive
  }
}

function assertNever(x: never): never {
  throw new Error(`Unexpected state: ${JSON.stringify(x)}`);
}

If you later add a new variant, the compiler will flag every missing branch.

Narrowing with type guards

Custom guards make dynamic data usable without unsafe casts.

ts
type User = { id: string; name: string };
type Team = { id: string; members: User[] };

function isUser(v: unknown): v is User {
  return !!v && typeof v === "object" &&
    typeof (v as any).id === "string" &&
    typeof (v as any).name === "string";
}

function isTeam(v: unknown): v is Team {
  return !!v && typeof v === "object" &&
    typeof (v as any).id === "string" &&
    Array.isArray((v as any).members) &&
    (v as any).members.every(isUser);
}

function greet(v: unknown): string {
  if (isUser(v)) return `Hello, ${v.name}!`;
  if (isTeam(v)) return `Team ${(v.members[0]?.name) ?? "unknown"} reporting`;
  return "Unknown entity";
}

Result type for error handling

Return values explicitly instead of throwing. It composes well and plays nicely with await.

ts
type Ok<T> = { ok: true; value: T };
type Err<E = unknown> = { ok: false; error: E };
type Result<T, E = unknown> = Ok<T> | Err<E>;

const ok = <T>(value: T): Ok<T> => ({ ok: true, value });
const err = <E>(error: E): Err<E> => ({ ok: false, error });

async function json<T>(url: string): Promise<Result<T, Error>> {
  try {
    const res = await fetch(url);
    if (!res.ok) return err(new Error(`HTTP ${res.status}`));
    return ok(await res.json() as T);
  } catch (e) {
    return err(e as Error);
  }
}

(async () => {
  const r = await json<{ login: string }>(
    "https://api.github.com/users/denoland",
  );
  if (r.ok) {
    console.log("login:", r.value.login);
  } else {
    console.error("fetch failed:", r.error.message);
  }
})();

Branded (opaque) types

Prevent unit mixups while staying zero-cost at runtime.

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

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

function UserId(v: string): UserId {
  return v as UserId;
}
function OrgId(v: string): OrgId {
  return v as OrgId;
}

function getUser(u: UserId) {/* … */}
function getOrg(o: OrgId) {/* … */}

const u = UserId("u_123");
const o = OrgId("o_456");

// getUser(o); // ❌ compile error
getUser(u); // ✅ ok

You can brand numbers, strings, or even objects to separate domains that share the same primitive representation.

Lookup tables with Record and as const

Replace switch statements and stringly-typed maps with typed dictionaries.

ts
const STATUS = {
  draft: { color: "#6b7280", label: "Draft" },
  published: { color: "#16a34a", label: "Published" },
  archived: { color: "#9ca3af", label: "Archived" },
} as const;

type StatusKey = keyof typeof STATUS; // "draft" | "published" | "archived"

function renderStatus(s: StatusKey): string {
  const { color, label } = STATUS[s];
  return `<span style="color:${color}">${label}</span>`;
}

This creates a single source of truth with strong keys and exact literals.

API helpers with generics

Create a tiny fetcher that infers response types per endpoint.

ts
type EndpointSpec = {
  method: "GET" | "POST";
  path: string;
  params?: Record<string, string | number | boolean | undefined>;
  body?: unknown;
  response: unknown;
};

async function call<E extends EndpointSpec>(
  base: string,
  spec: Omit<E, "response">,
): Promise<E["response"]> {
  const url = new URL(spec.path, base);
  if (spec.params) {
    for (const [k, v] of Object.entries(spec.params)) {
      if (v !== undefined) url.searchParams.set(k, String(v));
    }
  }
  const res = await fetch(url, {
    method: spec.method,
    headers: { "content-type": "application/json" },
    body: spec.body ? JSON.stringify(spec.body) : undefined,
  });
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  return await res.json() as E["response"];
}

// Usage:
type GetUser = EndpointSpec & {
  method: "GET";
  path: "/api/user";
  params: { id: string };
  response: { id: string; name: string };
};

(async () => {
  const user = await call<GetUser>("https://example.com", {
    method: "GET",
    path: "/api/user",
    params: { id: "u_1" },
  });
  // user is { id: string; name: string }
})();

satisfies for shape validation without widening

Keep the value exact while enforcing structure.

ts
type EnvConfig = {
  API_URL: string;
  TIMEOUT_MS: number;
  ENABLE_EXPERIMENTS: boolean;
};

const env = {
  API_URL: "https://api.example.com",
  TIMEOUT_MS: 2000,
  ENABLE_EXPERIMENTS: false,
} as const satisfies EnvConfig;

// env.TIMEOUT_MS is 2000 (readonly, exact number), not `number`

Utility types that earn their keep

  • Partial<T> / Required<T>: optionalize or require fields.
  • Pick<T, K> / Omit<T, K>: slice shapes.
  • Readonly<T>: defensive returns.
  • NonNullable<T>: strip null | undefined.

A light “deep partial” that’s good enough for many forms:

ts
type DeepPartial<T> = {
  [K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K];
};

type User = {
  id: string;
  name: string;
  address: { city: string; zip: string };
};

const patch: DeepPartial<User> = {
  address: { city: "New City" },
};

Enum alternatives: union literals with helpers

Prefer union literals over enum for better ergonomics and tree-shaking.

ts
const Roles = ["admin", "editor", "viewer"] as const;
type Role = typeof Roles[number];

function isRole(v: string): v is Role {
  return (Roles as readonly string[]).includes(v);
}

function canEdit(role: Role) {
  return role === "admin" || role === "editor";
}

Putting it together

These patterns compose well:

  • Use discriminated unions for state + exhaustive switch.
  • Introduce Result<T,E> for recoverable errors.
  • Brand IDs to prevent cross-domain mixups.
  • Drive maps with Record + as const.
  • Use generics for API surfaces with per-endpoint inference.
  • Keep exactness with satisfies and as const.

They improve correctness with minimal noise—and they scale from scripts to larger systems.