C.W.K.
Stream
Lesson 05 of 06 · published

Type Annotations: Where They Go, Where They Don't

~11 min · foundations, annotations, syntax, where-to-annotate

Level 0Curious
0 XP0/93 lessons0/23 achievements
0/100 XP to next level100 XP to go0% complete
"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 use extends instead 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.

Inference catches more than you think: 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

I lean toward more annotations on exports and fewer inside private functions. cwkPippa's 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.

Code

The five places annotations live·typescript
// All five annotation locations in one snippet.

// 1. Variable annotation
const greeting: string = 'hi';

// 2. Parameter annotations
// 3. Return type annotation
function repeat(text: string, count: number): string {
  return text.repeat(count);
}

// 4. Property annotations (inside an interface)
interface User {
  id: number;
  name: string;
  email: string | null;
}

// 5. Type parameter constraint
function first<T extends unknown[]>(arr: T): T[0] {
  return arr[0];
}

first([1, 2, 3]);          // returns number
first(['a', 'b', 'c']);    // returns string
Annotate at the doorways·typescript
// Over-annotation (noisy):
const x: number = 1;                                         // colon unnecessary — inference already knows
const names: string[] = ['Pippa', 'Dad'];                    // unnecessary
const user: { name: string } = { name: 'Pippa' };            // unnecessary

// Right-sized (doorway rule):
function makeUser(name: string, age: number): User {         // doorway in + doorway out — annotate
  const id = generateId();                                   // inferred — clean
  const stamped = { id, name, age, createdAt: new Date() };  // inferred — clean
  return stamped;                                            // return is checked against User
}

External links

Exercise

Take a function you wrote in any earlier lesson. Remove every annotation that the compiler doesn't strictly need. Then add an explicit return type to the function itself. Compare the readability of the before and after — which version reads more like a contract?
Hint
If you remove an annotation and the compiler immediately complains, the annotation was load-bearing — put it back. If the compiler stays silent, the annotation was decorative — leave it out.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.