Type the Edges, Let the Middle Infer
The most common way to write unpleasant TypeScript is to annotate everything. Every variable gets a type, every intermediate value is declared, and the file fills with types that repeat what the code already says. It is slow to write, noisy to read, and worse than useless when the code changes — now you have two things to update instead of one.
The habit to build instead is to type the edges. A module's edges are its exported function signatures, its public types, its component props, and the shapes of data entering from outside. Type those carefully. Inside a function, let inference do the work: local variables, mapped results, chained calls and callback parameters all get their types for free and stay correct automatically.
This is not laziness but a design principle: types at the boundary are a contract and should be explicit, while types inside are an implementation detail, and pinning them down only makes refactoring harder.
The one internal annotation worth keeping is a return type on any function long enough that you cannot see all its returns at once. That turns a mistake in one branch into an error inside the function rather than a confusing error somewhere else.
// Noisy: every one of these is inferred perfectly already
const names: string[] = students.map((s: Student): string => s.name);
const count: number = names.length;
const first: string | undefined = names[0];
// Clear: the boundary is typed, the middle is not
export function topStudents(students: Student[], limit: number): string[] {
const sorted = [...students].sort((a, b) => b.marks - a.marks);
const top = sorted.slice(0, limit);
return top.map(s => s.name);
}
// Worth annotating: several return paths, one of them easy to get wrong
export function grade(marks: number): "A" | "B" | "C" | "F" {
if (marks >= 85) return "A";
if (marks >= 70) return "B";
if (marks >= 33) return "C";
return "F";
} - A quick test for whether an annotation earns its place: delete it. If the editor still shows the same type and no error appears anywhere, it was documentation for a fact the compiler already knew.
Getting Rid of any
In practice, any enters a codebase through four doors, and each has a better answer. Knowing them turns "avoid any" from a slogan into something you can actually act on.
The first is parsed data: JSON.parse and res.json() both produce any. Type the result unknown and validate it. The second is caught errors: under strict the catch variable is already unknown, and instanceof Error is how you earn the right to read .message. The third is untyped libraries: install @types/ if it exists, and if not, write a small declare module covering only the functions you call.
The fourth is the honest one — a type you have not worked out yet, usually mid-refactor. There, the tool is // @ts-expect-error rather than any. It suppresses the error on the next line and, crucially, reports an error if that line ever stops failing. So the suppression cannot outlive the problem: fix the underlying issue and the comment itself tells you to remove it. // @ts-ignore has no such feedback and will sit in your codebase for years.
Where you must use any, leave a comment saying why and what would replace it. An any with a reason is a decision; an any without one is a leak.
// 1. Parsed data -> unknown plus a guard
const body: unknown = await res.json();
if (!isStudent(body)) throw new Error("Bad response");
// 2. Errors -> narrow before use
try {
save();
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
console.error(message);
}
// 3. Untyped library -> describe only what you use
declare module "legacy-chart" {
export function render(el: HTMLElement, data: number[]): void;
}
// 4. Not sure yet -> a suppression that expires
// @ts-expect-error the SDK's types are wrong; remove when v3 ships
widget.configure({ mode: "compact" });
// If that line ever becomes valid:
// ERROR: Unused '@ts-expect-error' directive. - A useful intermediate step when a type is genuinely open-ended:
Record<string, unknown>instead ofany. It says "an object with unknown values", which still forces a check before use but allows key access.
Model States, Not Flags
The single highest-leverage design habit in TypeScript is making invalid states impossible to construct. Most bugs in front-end code are not wrong calculations; they are objects in a combination of states nobody intended, being rendered by code that assumed otherwise.
Boolean flags invite exactly that. Three booleans permit eight combinations, and a feature with three real states then has five states nobody has thought about. Optional properties do the same thing more quietly: data? and error? together permit both present and neither present.
A discriminated union removes the possibility rather than defending against it. Each member carries only the fields that make sense for that state, so message simply does not exist when the state is success. The rendering code becomes a switch with no guard clauses, and the exhaustiveness check tells you every place to update when a new state arrives.
Apply the same thinking to function arguments: if a comment is needed to explain which of four optional parameters go together, the type is doing too little, and two functions or one union would say it properly.
// Permits eight combinations; three are real
interface ScreenStateBad {
isLoading: boolean;
hasError: boolean;
isEmpty: boolean;
data?: Student[];
error?: string;
}
// Permits exactly the states that exist
type ScreenState =
| { status: "loading" }
| { status: "error"; message: string }
| { status: "empty" }
| { status: "ready"; students: Student[] };
function render(state: ScreenState): string {
switch (state.status) {
case "loading": return "Loading...";
case "error": return state.message;
case "empty": return "No students yet";
case "ready": return `${state.students.length} students`;
default: {
const exhaustive: never = state;
throw new Error(`Unhandled state: ${JSON.stringify(exhaustive)}`);
}
}
} - The phrase to remember is "make illegal states unrepresentable". It is the difference between a type that describes your data and a type that protects it.
as const, satisfies, and Branded Types
as const freezes a literal into its narrowest form: string values become literal types, arrays become readonly tuples, and everything becomes read-only. It is how a plain list of options becomes a union type, and how a configuration object stops being widened into vague string and number.
satisfies solves a real annoyance. Annotating a constant with a type checks it, but it also widens the constant to that type, so you lose the specific information about what you actually wrote. satisfies performs the check without the widening: the value is verified against the type, and the inferred type stays as specific as the literal. Use it for configuration objects, route tables and palettes — anywhere you want both validation and precise autocomplete.
Branded types address the consequence of structural typing that bites hardest: a UserId and an OrderId that are both number are interchangeable, so passing the wrong one compiles. Intersecting the primitive with a marker property produces a type nothing else satisfies, so the only way to create one is a deliberate conversion — which you put in one place, next to the validation. The marker exists only in the type system; at runtime the value is still a plain number.
// as const: a list becomes a union
const ROLES = ["admin", "teacher", "student"] as const;
type Role = typeof ROLES[number]; // "admin" | "teacher" | "student"
// Annotation checks, but widens and loses detail
type Palette = Record<string, [number, number, number]>;
const annotated: Palette = { red: [255, 0, 0], green: [0, 255, 0] };
annotated.blue; // allowed — the key set was widened to any string
// satisfies checks without widening
const palette = {
red: [255, 0, 0],
green: [0, 255, 0]
} satisfies Palette;
palette.red; // [number, number, number] — still precise
palette.blue; // ERROR: 'blue' does not exist on this object
// Branded types: two numbers that are not interchangeable
type UserId = number & { readonly __brand: "UserId" };
type OrderId = number & { readonly __brand: "OrderId" };
function toUserId(value: number): UserId {
return value as UserId; // one deliberate conversion, in one place
}
function loadUser(id: UserId) { /* ... */ }
loadUser(toUserId(118)); // OK
loadUser(118); // ERROR: number is not assignable to UserId - Use branding where mixing up two values of the same primitive type would cause real damage: entity ids, currency amounts in different units, raw versus escaped HTML, validated versus unvalidated input.
Rules for the Escape Hatches
TypeScript gives you several ways to overrule it, and every one of them is occasionally the right answer. What separates a healthy codebase from a decorative one is whether those escapes are deliberate and rare, or reflexive. They all share one property: they move responsibility from the compiler to you, silently and permanently, and nothing ever revisits them.
So the working rule is: when an error appears, first assume the compiler is right. It usually is, and the error is describing a real case you have not handled. Reach for an escape hatch only when you can say out loud what you know that the compiler cannot — and then write that sentence in a comment.
as— a claim, not a conversion. Acceptable for DOM elements you wrote yourself; suspicious anywhere data is involvedas unknown as X— a double assertion, used only to defeat a rejection TypeScript made on purpose. Treat it as a review flag!(non-null assertion) — fine immediately after a check the compiler cannot see; a bug waiting to happen when used to quiet a warningany— switches off all checking, including everywhere the value travels afterwards. Preferunknown// @ts-expect-error— the good suppression: it expires by itself when the underlying problem is fixed// @ts-ignore— the bad suppression: silent, permanent, and invisible in review- Turning off a
strictsub-flag to make errors disappear — almost never the right answer, and hardest of all to undo later
- A practical review habit: search the diff for
as,anyand@ts-. Those four patterns cover nearly every place type safety was traded away, and each one deserves a sentence of justification.
Habits That Keep a Project Healthy
The last group is about process rather than syntax, and it matters more than any individual type: a project can have excellent types and still rot if nothing enforces them.
The non-negotiable one is running tsc --noEmit in continuous integration. Editors and bundlers do not enforce types — bundlers strip them without checking — so without that step, type errors accumulate silently until someone runs the compiler and finds four hundred.
The second is adding ESLint with typescript-eslint. It catches things the compiler deliberately permits, and the type-aware rules in particular are worth the setup: a floating promise that nobody awaited is a common and hard-to-find bug that no amount of careful typing prevents.
Beyond that, keep types where they are used. A type belonging to one component or one module lives with it; only genuinely shared shapes need a common location. A single enormous types.ts that every file imports from becomes a dependency knot and tells you nothing about which module owns what.
- Run
tsc --noEmitin CI — nothing else enforces your types - Add ESLint with
typescript-eslint, including the type-aware rules for floating promises and unsafe assignments - Enable
stricton day one of a project; migrate an old codebase one sub-flag at a time - Keep types beside the code that owns them; reserve a shared module for shapes several features genuinely share
- Prefer deriving types (
Pick,Omit,ReturnType,typeof) over copying them - Validate every piece of data entering the program; types alone prove nothing about what arrived
- Stop before the clever type. If a type needs a comment explaining how it works, a simpler one is usually available
- Upgrade TypeScript regularly and read the release notes — new versions catch more bugs, and occasionally surface some you already had
- Good TypeScript is not maximal TypeScript. The goal is code that is hard to use incorrectly and easy to change, not a type system exercise. When a type stops paying for itself in caught bugs, simplify it.
