12 TypeScript Patterns That Delete Bug Classes
Practical, production-ready patterns that make whole categories of mistakes impossible — before your code even runs.
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 errorTip: 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 errorsatisfies 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 guardPair 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 errorAPIs 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 typoThe 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 +
assertNeverin every switch - Validate external input with a schema; infer types from it
- Return
Resultfrom async work; handle all branches - Prefer
readonly/as constandReadonlyMap/Array - Use
satisfiesfor exact shapes; ban key typos - Enable
noUncheckedIndexedAccess; preferRecord - Encode domain formats with template literal types
- Write type guards; ban blanket
ascasts in reviews - Model flow with phase-typed builders where order matters
- Use typed event maps; kill stringly events
- Accept
unknownat boundaries; neverany
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.









