10 TypeScript Patterns That Make Code Explain Itself
· 12 min read
Comments rot. Types don’t — the compiler checks them on every build.
Here’s a claim worth testing: the best comment you’ll ever write is the one you skip, because the type already said it. Comments describe intent once, then sit there. Nobody re-checks a comment when the code around it changes six months later. A type gets no such luxury — the compiler re-verifies it on every build. A lying type gets caught. A lying comment just keeps lying, quietly, for as long as nobody notices. Think of it as a courtroom, not a comment box: the compiler is the witness, the type is the testimony, and testimony gets cross-examined every single build. A comment just gets taken at its word - pun intended.
See these two versions of the same function. A comment above a function
// callback fires with the new value; the type depends on which key changed
function on(eventName, callback) {
//...
}Versus,
function on<Key extends keyof Type>(
eventName: `${Key}Changed`,
callback: (value: Type[Key]) => void
) {
//...
}Both try to say the same thing. Only one breaks the build the moment it stops being true — because only one is checked by something other than a human remembering to update it.
That’s the argument behind everything below. Not “add more types.” Plenty of heavily typed code is still unreadable — all any, as casts, interfaces named Props2. This is narrower: ten specific patterns that turn a signature into something a teammate can read cold, six months out, without opening the implementation, and trust.
Most of these shipped in TypeScript within the last four years, which means plenty of frontend codebases haven’t picked them up yet — not from resistance, just because a feature landing in a release note doesn’t automatically show up in a team’s habits. Some are old TypeScript wearing a sharper technique, not new syntax at all. What ties all ten together isn’t novelty. It’s that each one moves a fact a reader needs out of a sentence and into something the compiler is willing to enforce.
1. satisfies — keep the specific type, not just the general one
Define a config object and you want TypeScript to check it against a shape like Record<string, RouteConfig>. The old move, a type annotation, works — but it throws away information. Every property on routes now reads as RouteConfig, even though TypeScript knew a second ago that routes.home was specifically { path: "/", exact: true }.
TypeScript 4.9 (November 15, 2022) added satisfies to fix this. It checks an expression against a type without changing the expression’s own inferred type:
type RouteConfig = { path: string; exact?: boolean };
const routes = {
home: { path: "/", exact: true },
about: { path: "/about" },
} satisfies Record<string, RouteConfig>;
routes.home.exact; // still known to be `true`, not `boolean | undefined`Read that signature and you know two things at once: this object matches Record<string, RouteConfig>, and each entry keeps its own literal shape. Hover over routes.home.exact in your editor and it shows true. The annotated version would show boolean | undefined and send you back up to the object literal to check what was actually there — the exact round trip a self-documenting signature is meant to remove. It’s become the default choice for theme objects and default-options objects, anywhere a value needs validating without losing its specifics.
The routes example leaves the key set open — any string key is allowed, as long as its value matches RouteConfig. Just as often, you want the opposite: a closed, specific set of keys, with each value still keeping its literal type. satisfies does both at once:
type IconName = "home" | "settings" | "profile";
const icons = {
home: { size: 24, viewBox: "0 0 24 24" },
settings: { size: 20, viewBox: "0 0 20 20" },
profile: { size: 24, viewBox: "0 0 24 24" },
} satisfies Record<IconName, { size: number; viewBox: string }>;
icons.settings.size; // still known to be `20`, not widened to `number`
// const broken = {
// home: { size: 24, viewBox: "0 0 24 24" },
// banner: { size: 32, viewBox: "0 0 32 32" }, // error: "banner" isn't in IconName
// } satisfies Record<IconName, { size: number; viewBox: string }>;The signature now documents two constraints in one line: icons can only have the three keys IconName allows — add a banner icon without updating the union and the object literal itself fails to compile1 — and each entry still reports its own literal size, not a widened number. A plain type annotation on icons would have gotten the closed key set but lost the literals; the looser Record<string, ...> from the first example gets the literals but not the closed set. satisfies is the only one of the three that keeps both.
2. const type parameters — stop sprinkling as const everywhere
Pass an array or object into a generic function, and TypeScript widens it — ["a", "b"] becomes string[], not the tuple ["a", "b"]. For years the workaround was as const at every call site, a small ritual that works but adds noise the reader has to filter out.
TypeScript 5.0 (March 16, 2023) moved that behavior into the function definition with a const modifier on the type parameter:
function getNamesExactly<const T extends { names: readonly string[] }>(arg: T) {
return arg.names;
}
getNamesExactly({ names: ["Milo", "Otis"] });
// return type: readonly ["Milo", "Otis"] — no `as const` at the call siteThe signature now documents the contract directly: whatever you pass keeps its literal shape, guaranteed by the function itself rather than by every caller remembering a habit. It shows up in small custom hooks constantly — a useToggle that should return the precise tuple [boolean, () => void], not a loosely typed array, benefits from exactly this. No one has to dig through three call sites to spot the one that skipped the annotation.
3. Discriminated unions with exhaustive checks — a state machine in the type
Loading states are the classic frontend mess: { loading: boolean, data?: T, error?: string }, where loading: true and error: "oops" can both be true at once and nothing stops it. A discriminated union makes the states mutually exclusive and names each one:
type FetchState<T> =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: T }
| { status: "error"; error: string };
function render(state: FetchState<User>) {
switch (state.status) {
case "idle": return null;
case "loading": return "Loading…";
case "success": return state.data.name; // `data` only exists here
case "error": return state.error; // `error` only exists here
default: {
const _exhaustive: never = state; // compile error if a case is missing
return _exhaustive;
}
}
}A picture helps here more than prose does:
---
title: Fetch State
---
stateDiagram-v2
[*] --> idle
idle --> loading: fetch()
loading --> success: resolves
loading --> error: rejects
success --> loading: refetch()
error --> loading: retry()Discriminated unions aren’t new. What makes them documentation, not just structure, is the never check in the default branch. It turns “can loading and error both be true?” from a question raised in a pull request into a question the compiler already answered. Add a fifth state and forget to handle it, and the build breaks at that exact call site. No comment thread does that automatically.
4. NoInfer — say which part of the signature is fixed
Generic functions sometimes infer from the wrong argument. Picture a function that picks a default from a known set:
function pickDefault<T>(options: T[], fallback: T): T {
return options.includes(fallback) ? fallback : options[0];
}
pickDefault(["a", "b"], "c"); // `T` widens to "a" | "b" | "c" — `fallback` shouldn't drive thisTypeScript 5.4 (March 6, 2024) added NoInfer<T> to mark an argument as ineligible for inference:
function pickDefault<T>(options: T[], fallback: NoInfer<T>): T {
return options.includes(fallback) ? fallback : options[0];
}
pickDefault(["a", "b"], "c"); // now a type error: "c" isn't in "a" | "b"Small feature, honest signature: options decides what T is, fallback just has to match it. That distinction used to live only in the author’s head — which is exactly how a useReducer helper’s initial-state type quietly gets widened by whatever the reducer’s action union includes. The bug shows up as broken autocomplete three files from the actual cause, and takes real effort to trace back.
5. using declarations — write cleanup where the resource is created
Anything needing teardown — a subscription, an AbortController, a mutex — usually ends up in a try/finally block, cleanup logic physically far from setup. TypeScript 5.2 (August 24, 2023) added using declarations, implementing the TC39 Explicit Resource Management proposal:
function subscribeToResize(el: HTMLElement, onResize: () => void) {
const observer = new ResizeObserver(onResize);
observer.observe(el);
using _disposable = {
[Symbol.dispose]: () => observer.disconnect(),
};
// scope ends, disconnect() runs automatically — no finally block needed
}using sits right next to the resource it disposes of. Compare that to the usual React effect pattern — addEventListener at the top of useEffect, removeEventListener twenty lines later in the cleanup function it returns — where a reader has to hold both ends in their head to confirm they match. using collapses that distance to zero, and it applies just as directly to a plain addEventListener/removeEventListener pair as it does to a ResizeObserver:
function onOutsideClick(el: HTMLElement, onOutside: () => void) {
const handleClick = (e: MouseEvent) => {
if (!el.contains(e.target as Node)) onOutside();
};
document.addEventListener("click", handleClick);
using _disposable = {
[Symbol.dispose]: () => document.removeEventListener("click", handleClick),
};
// scope ends, removeEventListener runs with the exact same handler reference —
// no risk of the add/remove pair drifting out of sync
}The listener that gets removed is guaranteed to be the same reference that got added, because they’re declared four lines apart instead of on opposite ends of a component. That’s the actual bug using prevents here: a removeEventListener call with a handler that’s been re-created since the matching addEventListener, which silently leaves the old listener attached forever. One setup note before you reach for it: Symbol.dispose isn’t in TypeScript’s default lib, so this won’t compile until your tsconfig.json’s lib array includes "esnext.disposable" (or the broader "esnext") — a self-documenting snippet that fails to build on paste isn’t documenting anything. It’s still early in frontend code specifically, but as more browser and Node APIs pick up Symbol.dispose, expect it to spread past the toy examples.
6. Branded types — make the type name carry the domain meaning
TypeScript’s structural typing is usually a feature: two objects with the same shape are the same type, no ceremony. For IDs, that’s a liability. UserId and OrderId are both string underneath, so nothing stops you passing one where the other belongs. Branded types close that gap by attaching a fake, compile-time-only tag — a lighter, dependency-free version of the same idea Effect’s Brand module formalizes with its own runtime constructors and validation built in; the snippet below is the plain-TypeScript version, not Effect’s API:
type Brand<T, B extends string> = T & { readonly __brand: B };
type UserId = Brand<string, "UserId">;
type OrderId = Brand<string, "OrderId">;
function cancelOrder(id: OrderId) { /* ... */ }
declare const someUserId: UserId;
cancelOrder(someUserId); // error: UserId isn't assignable to OrderIdRead function cancelOrder(id: OrderId) and you know exactly which flavor of string is expected, no comment required — and the compiler backs that reading up. It targets one of the most common frontend bug classes there is: a cart reducer that takes a productId where a variantId belongs, both valid strings, no runtime error until the wrong item vanishes from someone’s order. The __brand tag never exists at runtime — it’s erased on compile, so the safety is free.
The same trick fixes a nastier, security-flavored version of the same problem: telling a sanitized string apart from a raw one. dangerouslySetInnerHTML doesn’t care whether the string you hand it has been through a sanitizer — it renders whatever it gets, a well-documented path stored XSS takes into a React app. Brand the output of your sanitizer, and the type system refuses anything that hasn’t been through it:
type SafeHtml = Brand<string, "SafeHtml">;
declare function sanitize(raw: string): SafeHtml;
function render(html: SafeHtml) {
return { __html: html };
}
const userComment = "<img src=x onerror=alert(1)>";
render(userComment); // error: string isn't assignable to SafeHtml
render(sanitize(userComment)); // fine — sanitize() is the only way to produce a SafeHtmlNobody has to leave a comment reading // make sure this is sanitized first above render. The signature already refuses to compile without it.
7. Template literal types — typed strings that read like an API
String-shaped values — event names, route paths, CSS custom properties — are usually just string, which throws away a pattern a reader could otherwise see at a glance. Template literal types let the type carry that pattern. The TypeScript Handbook builds a typed event API this way:
type PropEventSource<Type> = {
on<Key extends string & keyof Type>(
eventName: `${Key}Changed`,
callback: (newValue: Type[Key]) => void
): void;
};
declare function makeWatchedObject<Type>(obj: Type): Type & PropEventSource<Type>;
const person = makeWatchedObject({ firstName: "Saoirse", age: 26 });
person.on("firstNameChanged", (name) => name.toUpperCase()); // `name` is `string`
person.on("firstName", () => {}); // error: not a valid event nameThe signature of on documents every valid event name — firstNameChanged, ageChanged — and rejects typos before the code runs. Nobody keeps a comment listing valid events; the type generates the list. The same cross-multiplication trick shows up in community Tailwind tooling, not the framework’s own bundled types: projects like tailwindcss-classnames generate a template-literal union of every valid utility class from a project’s config, so an invalid class string becomes a type error instead of a silent no-op in the browser. For more worked examples of the same trick — role-gated event unions, chaining it with Uppercase/Lowercase string manipulation types — see Exploring TypeScript Template Literal Types (James Milner, April 21, 2021).
8. Generic components without React.FC — let the signature describe what it accepts
React.FC handles generics badly, so the React TypeScript Cheatsheet — the community’s de facto reference for this stuff — recommends skipping it in favor of a plain generic function instead:
type DropdownProps<T> = {
items: T[];
onSelect: (item: T) => void;
labelFor: (item: T) => string;
};
function Dropdown<T,>({ items, onSelect, labelFor }: DropdownProps<T>) {
return (
<select onChange={(e) => onSelect(items[Number(e.target.value)])}>
{items.map((item, i) => (
<option key={i} value={i}>{labelFor(item)}</option>
))}
</select>
);
}(The trailing comma after T isn’t a typo — in a .tsx file, <T> alone reads as a JSX tag, so the comma disambiguates it.) Call <Dropdown items={users} onSelect={...} labelFor={...} /> and TypeScript infers T as User for that call, then holds every prop to it. Compare that to the usual fallback when generics feel like too much ceremony — typing items as any[], which compiles but tells a new teammate nothing about what the component supports. Read the generic signature once, and you know Dropdown works with any item type, not just whichever one an earlier version hardcoded.
9. ComponentPropsWithoutRef — a props type that doesn’t overpromise
Wrapping a native element is common — a custom Button around <button>. It’s tempting to grab all its props with ComponentProps<'button'>. But that type includes a ref prop, implying the wrapper forwards it. If it doesn’t, that’s a silent lie in the signature: a caller passes ref, expects DOM access, and gets nothing, because the wrapper never spread it down. The React TypeScript Cheatsheet recommends ComponentPropsWithoutRef for exactly this reason:
type ButtonProps = React.ComponentPropsWithoutRef<"button"> & {
variant?: "primary" | "secondary";
};
function Button({ variant = "primary", ...rest }: ButtonProps) {
return <button className={`btn-${variant}`} {...rest} />;
}If Button later grows a real forwardRef, the type swaps to ComponentPropsWithRef — that one-word change is the whole changelog entry. The props type tells the truth about ref support without a sentence of prose, and it tells that truth at every call site at once, which a comment on the component definition alone never could.
10. Type predicates — narrowing that survives a function call
TypeScript narrows types automatically inside an if block, but that narrowing doesn’t survive a function call by default.
type User = { id: string; email: string };
type MaybeUser = User | null | undefined;
function greet(u: MaybeUser) {
if (u != null && typeof u.email === "string") {
console.log(u); // u is User
return;
}
console.log(u); // us is MaybeUser
}TypeScript forgets what it just verified. Pull the check into isValidUser(x) and a type predicate return type the type gets narrowed down:
type User = { id: string; email: string };
type MaybeUser = User | null | undefined;
function isValidUser(u: MaybeUser): u is User {
return u != null && typeof u.email === "string";
}
function greet(u: MaybeUser) {
if (isValidUser(u)) {
console.log(u); // u is User
return;
}
console.log(u); // u is { null | undefined }
}The u is User return type is a documented promise: call this, get true back, and the compiler treats u as User from there. A plain boolean can’t make that promise — and the same signature composes free with Array.prototype.filter, so users.filter(isValidUser) narrows the whole array to User[] in one line. That’s where this pattern earns its keep most in frontend code: validating a list of maybe-null form entries or API results before rendering them.
The honest caveat
None of this replaces comments outright. Types document shape and contract — what a value can be, what a function accepts, what states exist. They say nothing about why: why this endpoint retries three times and not five, why this component exists instead of reusing an older one, why one edge case matters to one particular customer. That’s still prose’s job, and a good comment explaining a business reason is worth keeping right where it is.
What these ten patterns do is narrow the space where a comment is your only option. Each one pushes a fact — this string is one of these exact values, this ID can’t be confused with that one, this component takes any item type, this ref either forwards or doesn’t — out of a sentence that can drift and into a signature the compiler checks on every build. The compiler doesn’t get tired, doesn’t forget context, and doesn’t skip a re-check because the deadline is close. That’s the actual advantage over prose: not that types are smarter, but that the testimony gets cross-examined every time, and a comment never does.
satisfiesperforms the same excess-property check on object literals that a normal type annotation would — TypeScript’s own team has discussed this exact mechanic (whether an unlisted key like"banner"should error) in microsoft/TypeScript#52999.↩