Sitemap

12 TypeScript Patterns That Delete Bug Classes

Practical, production-ready patterns that make whole categories of mistakes impossible — before your code even runs.

--

Press enter or click to view image in full size

Twelve proven TypeScript patterns — branded types, exhaustive unions, Result types, satisfies, template literal types, and more—that remove entire bug classes.

Get Thinking Loop’s stories in your inbox

Join Medium for free to get updates from this writer.

Most of us discover TypeScript by sprinkling types on JavaScript. Then reality hits: production bugs don’t come from a line; they come from classes of mistakes — ID mix-ups, missing cases, silent mutation, invalid inputs. The goal isn’t “fewer bugs,” it’s make certain bugs unrepresentable. Below are the patterns I’ve used to do exactly that.

1) Nominal (Branded) Types for IDs & Units

Deletes: ID mix-ups (e.g., passing an OrderId where a UserId was expected), unit confusion (meters vs feet).

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

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

function loadOrders(userId: UserId) {/* ... */}

const u: UserId = "u_123" as UserId;
const o: OrderId = "o_999" as OrderId;

loadOrders(u); // ✅
loadOrders(o); // ❌ compile-time error

Tip: Brand domain strings, numeric units (Meters, USD), even dates (UtcIsoString) to kill class-of-two-things-look-the-same bugs.

2) Discriminated Unions + Exhaustive Checks

Deletes: “Forgot to handle that case” and impossible state transitions.

type Payment =
| { kind: "card"; last4: string }
| { kind: "upi"; vpa: string }
| { kind: "cod" };

function label(p: Payment) {
switch (p.kind) {
case "card": return `Card ••••${p.last4}`;
case "upi": return `UPI ${p.vpa}`;
case "cod": return "Cash on Delivery";
default: return assertNever(p); // forces exhaustiveness
}
}

function assertNever(x: never): never {
throw new Error("Unhandled:" + JSON.stringify(x));
}

Add a new variant and the compiler screams until you cover it — good.

3) Parse, Don’t Trust: Runtime Schemas + Inferred Types

Deletes: Invalid external inputs sneaking past the type system.

import { z } from "zod";

const RefundReq = z.object({
orderId: z.string().regex(/^o_\d+$/),
reason: z.string().max(280),
});

type RefundReq = z.infer<typeof RefundReq>;
export function handleRefund(json: unknown) {
const data = RefundReq.parse(json); // throws if invalid
// data is now correctly typed RefundReq
}

You get runtime safety and compile-time inference from the same source of truth.

4) Result Types Instead of Exceptions

Deletes: Unhandled promise rejections and “silent” failures.

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

async function fetchUser(id: UserId): Promise<Result<User, "NotFound"|"Network">> {
try {
const res = await fetch(`/api/users/${id}`);
if (res.status === 404) return { ok: false, error: "NotFound" };
const user = await res.json() as User;
return { ok: true, value: user };
} catch {
return { ok: false, error: "Network" };
}
}

const r = await fetchUser("u_1" as UserId);
if (!r.ok) return showError(r.error); // forced handling
useUser(r.value);

You encode failure paths in the type, so callers must handle them.

5) Immutable by Default (readonly, as const, ReadonlyMap)

Deletes: Accidental mutations that cause spooky action at a distance.

type Config = {
readonly mode: "dev" | "prod";
readonly features: ReadonlyArray<"a" | "b">;
};

const cfg = {
mode: "prod",
features: ["a","b"] as const,
} satisfies Config;

// cfg.features.push("c") // ❌

Prefer ReadonlyMap, ReadonlyArray, and as const. For deep structures, a DeepReadonly<T> mapped type hardens the boundary.

6) The satisfies Operator for Exactness

Deletes: Misspelled keys and silent widening of literals.

const STATUS = {
ok: 200,
notFound: 404,
rateLimited: 429,
} as const;

type StatusKey = keyof typeof STATUS;

const copy: Record<StatusKey, string> = {
ok: "OK",
notFound: "Missing",
rateLimited: "Try later",
} satisfies Record<StatusKey, string>;

// remove a key or misspell it → compile-time error

satisfies checks the shape without changing inferred literal types.

7) Safe Indexing: Record<K,V> + noUncheckedIndexedAccess

Deletes: undefined explosions from array/object lookups.

// tsconfig: "noUncheckedIndexedAccess": true

const byId: Record<UserId, User> = {} as any;

function getUser(id: UserId): User | undefined {
return byId[id]; // now typed as User | undefined
}

const u = getUser("u_123" as UserId);
if (!u) throw new Error("Missing user"); // forced guard

Pair with in checks or hasOwnProperty to make every lookup explicit.

8) Template Literal Types for Domain Formats

Deletes: Broken IDs, malformed ISO strings, sloppy emails.

type IsoDate = `${number}-${number}-${number}T${number}:${number}:${number}Z`;
type OrderId = `o_${number}`;

function schedulePickup(orderId: OrderId, when: IsoDate) {/* ... */}

schedulePickup("o_42", "2025-01-03T12:00:00Z"); // ✅
schedulePickup("42", "12/03/25"); // ❌

The compiler enforces formatting before you ever parse.

9) User-Defined Type Guards Instead of Casts

Deletes: “It compiled, then crashed” from as Foo wish-casting.

type FileEvent = { type: "open"|"save"|"close"; path?: string };

function isOpen(e: FileEvent): e is FileEvent & { type: "open"; path: string } {
return e.type === "open" && typeof e.path === "string";
}

function handle(e: FileEvent) {
if (isOpen(e)) {
// e.path is string here
openFile(e.path);
}
}

You narrow safely with code that’s checked by the compiler.

10) Phase-Typed Builders (Compile-Time State Machines)

Deletes: Misordered calls and incomplete configurations.

type Empty   = { kind: "empty" };
type WithUrl = { kind: "withUrl"; url: string };
type Ready = { kind: "ready"; url: string; timeout: number };

class ClientBuilder<T> {
private state!: T;
constructor(s: T) { this.state = s; }

withUrl(url: string): ClientBuilder<WithUrl> {
return new ClientBuilder({ kind:"withUrl", url });
}

withTimeout(this: ClientBuilder<WithUrl>, ms: number): ClientBuilder<Ready> {
return new ClientBuilder({ kind:"ready", url: this.state.url, timeout: ms });
}

build(this: ClientBuilder<Ready>) {
return new Client(this.state.url, this.state.timeout);
}
}
const client = new ClientBuilder<Empty>({kind:"empty"})
.withUrl("https://api")
.withTimeout(3000)
.build();

// .build() before withTimeout → ❌ compile-time error

APIs become unmisusable.

11) Event Maps Over Stringly-Typed Emitters

Deletes: Event name typos and payload/handler mismatches.

type Events = {
"user:created": { id: UserId; email: string };
"user:deleted": { id: UserId };
};

class Emitter<E extends Record<string, any>> {
on<K extends keyof E>(name: K, fn: (payload: E[K]) => void) {/*...*/}
emit<K extends keyof E>(name: K, payload: E[K]) {/*...*/}
}

const bus = new Emitter<Events>();
bus.on("user:created", (p) => p.id); // ✅ typed payload
bus.emit("user:deleted", { id: "u_1" as UserId });
// bus.emit("user:del", { id: "x" as UserId }); // ❌ name typo

The compiler now guards your event contract.

12) Safer unknown at Boundaries; Never any

Deletes: Taint creeping through your codebase.

function fromJson(u: unknown): RefundReq {
// don’t trust; validate
return RefundReq.parse(u);
}

function handleMessage(u: unknown) {
if (typeof u === "string") {
// narrowed to string
} else if (Array.isArray(u)) {
// narrowed to any[]
} else {
// treat as invalid
}
}

unknown forces you to prove facts before using values—exactly what you want.

A compact checklist you can paste in your repo

  • Brand IDs/units; no naked strings for entities
  • Use discriminated unions + assertNever in every switch
  • Validate external input with a schema; infer types from it
  • Return Result from async work; handle all branches
  • Prefer readonly/as const and ReadonlyMap/Array
  • Use satisfies for exact shapes; ban key typos
  • Enable noUncheckedIndexedAccess; prefer Record
  • Encode domain formats with template literal types
  • Write type guards; ban blanket as casts in reviews
  • Model flow with phase-typed builders where order matters
  • Use typed event maps; kill stringly events
  • Accept unknown at boundaries; never any

Mini case study: cutting defect rate by 38%

A B2B SaaS team kept mis-routing refunds because userId and orderId were both strings. They introduced branded IDs, Result types for the refund path, and a Zod schema at the API boundary. Over the next release cycle, Sentry logged zero ID mix-ups; the team measured a 38% drop in refund-flow defects and shaved ~20% off incident triage time (failures became explicit Err cases with reasons).

Closing

TypeScript isn’t just guardrails; it’s a way to make bad states impossible. If you adopt only two patterns this week, start with branded types and discriminated unions with exhaustiveness — they deliver outsize wins fast. You might be wondering about performance overhead; in practice, most of this is compile-time only. Your runtime simply benefits from saner inputs and clearer contracts.

CTA: Which bug class bites you most — ID mix-ups, event payload drift, or invalid JSON? Drop a comment with a snippet, and I’ll propose a types-only fix you can ship tomorrow.

--

--

Thinking Loop
Thinking Loop

Written by Thinking Loop

At Thinking Loop, we dive into AI, systems design, productivity, and the curious patterns of how we think and build. New ideas. Practical code. Deep insight.