"Annotate the doorways, not the hallways."
Five annotation locations
Every TypeScript annotation lives in one of five places. Once you can name all five, you can write TS without thinking about syntax — your brain has a complete map of where the colons go.
- Variable annotations:
const x: number = 1. The colon binds the type to the binding name. - Parameter annotations:
function f(a: string, b: number). The colon binds the type to each parameter. - Return type annotations:
function f(): string. The colon binds to the function as a whole and constrains what it can return. - Property annotations (inside type/interface declarations):
{ name: string; age: number }. Each member of an object shape gets a colon. - Type parameter constraints:
function f<T extends string>(x: T). Generics useextendsinstead of:because the constraint is a subtype relationship.
That's it. Five places. Everything else in TypeScript's annotation syntax is a variation on one of these five.
The doorway rule
Beginners over-annotate. They put a type on every variable, including ones the compiler already knows. This makes the code noisy, fights the inference engine, and creates a maintenance burden when types change.
The pro move is to annotate at the doorways: function parameters (the doorway in), function return types (the doorway out), and exported public API surface (the doorway between modules). Inside a function body, let the compiler infer. Inside a function call site, let the compiler infer. The doorways are where you write the contract; the hallways are where the compiler runs the contract for you.
let x = 1 infers x: number. const arr = [1, 2, 3] infers arr: number[]. const obj = { name: 'Pippa' } infers obj: { name: string }. Writing : number, : number[], : { name: string } on these lines adds noise without adding safety. The compiler already knew. (Switching to const x = 1 narrows the inference to the literal type 1 — that's const's widening rule for primitives. The next lesson covers it.)When to annotate even when inference would work
Three exceptions to the doorway rule:
1. When inference picks a type that's too narrow. const status = 'idle' infers status: 'idle' (a literal type). If you meant "any one of a set of states," you need to widen: const status: 'idle' | 'loading' | 'done' = 'idle'.
2. When inference picks a type that's too wide. const colors = ['red', 'blue'] infers colors: string[]. If you want a tuple, annotate: const colors: ['red', 'blue'] = ['red', 'blue'] — or use as const.
3. When the type is the documentation. Sometimes an explicit return type on an exported function is the contract you want callers to read. Even if inference would give the same answer, the annotation signals intent.
Pippa's confession
frontend/src/lib/api.ts annotates every exported function's return type explicitly — those are the public boundaries of the module. The same file barely annotates anything inside a function body — the compiler can read the body line by line. The rule is consistent across the whole codebase, and it makes the file self-documenting: scroll to an export, read the signature, you have the contract.