TypeScript patterns I actually use
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.
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.
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.
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.
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.
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.
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.
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>: stripnull | undefined.
A light “deep partial” that’s good enough for many forms:
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.
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
satisfiesandas const.
They improve correctness with minimal noise—and they scale from scripts to larger systems.