Emmanuel Akinfulubi

Lesson 02 of 1815 min read

Inference, and When to Annotate

TypeScript already knows the type of almost everything you write. The skill is knowing the few places where saying it out loud earns its keep.

By the end: Let inference do the work, and annotate only where it pays.

Most TypeScript code contains very few types.

That surprises people who have only seen tutorial snippets, where every variable is laboriously annotated. In real code the compiler works out the vast majority of types on its own, and the annotations you write are concentrated in a few specific places. Learning which places is the difference between TypeScript feeling like a help and feeling like paperwork.

It already knows

Not one annotation, and every type is known

const title = 'Persuasion';
const year = 1817;
const finished = true;
const chapters = [1, 2, 3];
const author = { name: 'Austen', initials: 'JA' };

// Hover any of these in your editor and you will see a precise type.
// The compiler read the initialiser and drew the obvious conclusion.
console.log(title.toUpperCase());
console.log(year.toFixed(0));
console.log(finished === true);
console.log(chapters.map((n) => n * 2));
console.log(author.name.length);

Output


      

Because it knows, it can object. There is no annotation anywhere in the next panel, and yet the mistake is caught.

Inference is not a weaker kind of checking

TS2339
const author = { name: 'Austen', initials: 'JA' };

// `surname` was never part of this object, and the compiler knows the shape
// exactly, so this is an error despite nothing being annotated.
console.log(author.surname);

What the compiler says

error TS2339: Property 'surname' does not exist on type '{ name: string; initials: string; }'.

let and const infer different things

This is the first genuinely surprising rule, and it trips up nearly everyone once.

const infers narrowly, let infers widely

// `const` can never be reassigned, so the compiler records the exact value.
// The type of `direction` is not string — it is the literal type 'north'.
const direction = 'north';

// `let` can be reassigned, so the compiler widens to the general type.
// The type of `heading` is string.
let heading = 'north';
heading = 'south';

console.log(direction, heading);

// Same for numbers: `size` is the literal 10, `count` is number.
const size = 10;
let count = 10;
count = 11;

console.log(size, count);
What this compiles to
// `const` can never be reassigned, so the compiler records the exact value.
// The type of `direction` is not string — it is the literal type 'north'.
const direction = 'north';
// `let` can be reassigned, so the compiler widens to the general type.
// The type of `heading` is string.
let heading = 'north';
heading = 'south';
console.log(direction, heading);
// Same for numbers: `size` is the literal 10, `count` is number.
const size = 10;
let count = 10;
count = 11;
console.log(size, count);

Output


      

That widening is usually what you want, and occasionally exactly what you do not. Here is the case where it bites: a value that is fine on its own becomes unusable once it has been widened.

Widening loses the precision you needed

TS2345
function turn(to: 'north' | 'south'): string {
  return `turning ${to}`;
}

// `let` widened this to string, and string is not one of the two allowed values.
let heading = 'north';

console.log(turn(heading));

What the compiler says

error TS2345: Argument of type 'string' is not assignable to parameter of type '"north" | "south"'.

All three fixes, working

function turn(to: 'north' | 'south'): string {
  return `turning ${to}`;
}

// 1. const keeps the literal type.
const a = 'north';
console.log(turn(a));

// 2. Annotate the variable, so widening has a boundary to stop at.
let b: 'north' | 'south' = 'north';
b = 'south';
console.log(turn(b));

// 3. as const, when the value is inside something bigger.
const config = { heading: 'north' } as const;
console.log(turn(config.heading));

Output


      

Return types are inferred too

The compiler works out what comes back

// No return annotation, and the compiler knows this returns a number.
function area(width: number, height: number) {
  return width * height;
}

// It follows branches, and unions the results. This returns string | number.
function format(value: number, asText: boolean) {
  if (asText) return value.toFixed(2);
  return value;
}

console.log(area(3, 4));
console.log(format(3.14159, true));
console.log(format(3.14159, false));

// It handles async: this is Promise<string>, inferred.
async function fetchName() {
  return 'Ada';
}

fetchName().then((name) => console.log('resolved to', name));

Output


      

So should you annotate return types?

Reasonable people disagree, and here is the honest version of the argument.

For: an annotated return type makes the error appear inside the function, where the mistake is, instead of at every call site. It also stops a refactor from silently changing your public contract.

Against: it is one more thing to maintain, and inference is rarely wrong.

The compromise most teams land on, and the one this course uses: annotate returns on exported functions, let internal ones infer. Exported signatures are contracts and deserve to be written down; a three-line helper does not.

Where the error surfaces, with and without an annotation

// Annotated: if the body stops returning a number, the error appears HERE,
// on this function, which is where the mistake was made.
export function totalAnnotated(prices: number[]): number {
  return prices.reduce((sum, p) => sum + p, 0);
}

// Unannotated: the return type silently becomes whatever the body produces.
// Change the body and nothing complains here — the errors appear at every
// call site instead, which is further from the cause.
export function totalInferred(prices: number[]) {
  return prices.reduce((sum, p) => sum + p, 0);
}

Contextual typing: parameters that annotate themselves

This is the mechanism that makes callbacks pleasant, and it works in the opposite direction from everything above. Rather than reading a value to determine a type, the compiler reads the expected type to determine what a parameter must be.

Callback parameters need no annotation

const words = ['delta', 'alpha', 'charlie'];

// `w` is known to be string, because map on a string[] says so.
// Annotating it here would be noise.
console.log(words.map((w) => w.toUpperCase()));

// Same for sort, filter, reduce, forEach and every callback you are handed.
console.log([...words].sort((x, y) => x.localeCompare(y)));
console.log(words.filter((w) => w.startsWith('a')));

// It flows through your own functions too, as long as the parameter is typed.
function each(items: number[], visit: (item: number, index: number) => void) {
  items.forEach(visit);
}

each([10, 20], (item, index) => console.log(index, item));

Output


      

Extracted, and now there is nothing to infer from

TS7006
const words = ['delta', 'alpha'];

// Inline, `w` would have been inferred. Standing alone, it cannot be.
const shout = (w) => w.toUpperCase();

console.log(words.map(shout));

What the compiler says

error TS7006: Parameter 'w' implicitly has an 'any' type.

Where annotations genuinely earn their keep

Empty containers

An empty array has nothing to infer from, so the compiler gives up and produces any[] — or, under stricter settings, complains. This is the single most common place a real annotation is required.

Empty things need to be told what they will hold

// Without the annotation this would be any[], and every later mistake with it
// would go unnoticed.
const seen: string[] = [];
seen.push('alpha');
seen.push('beta');
console.log(seen.join(', '));

// Same for Map and Set, which are extremely common in the algorithms course.
const counts = new Map<string, number>();
counts.set('a', 1);
counts.set('b', 2);
console.log([...counts.entries()]);

const unique = new Set<number>();
unique.add(1);
unique.add(1);
console.log('size:', unique.size);

Output


      

When the inferred type is narrower than you need

Annotating to open a value up

type Status = 'draft' | 'published' | 'archived';

// Inferred as the literal 'draft', which cannot then be reassigned to
// anything else. Annotating widens it to exactly what you want.
let state: Status = 'draft';
state = 'published';
console.log(state);

// Same idea for arrays that will hold more than they start with.
const mixed: (string | number)[] = [1, 2];
mixed.push('three');
console.log(mixed);

Output


      

Function parameters, always

There is no argument here. A parameter has nothing to infer from except a call site the compiler is not allowed to look at, so with noImplicitAny on, parameters must be annotated. This is not a style choice, it is the one hard requirement.

Over-annotating

Now the other failure mode, which reads as diligence and is actually a cost. Every annotation is a claim you have to maintain, and one that repeats what the compiler already knows earns nothing.

Noise on the left, the same thing on the right

// Over-annotated: every one of these is redundant.
const nameA: string = 'Ada';
const yearA: number = 1815;
const tagsA: string[] = ['maths', 'engines'];
const pairA: { x: number; y: number } = { x: 1, y: 2 };

// Exactly equivalent, and this is what you should write.
const nameB = 'Ada';
const yearB = 1815;
const tagsB = ['maths', 'engines'];
const pairB = { x: 1, y: 2 };

console.log(nameA === nameB, yearA === yearB);
console.log(tagsA.length === tagsB.length);
console.log(pairA.x === pairB.x);

// Worse than redundant: this annotation is strictly WORSE than inference,
// because it throws away the literal types the object would have had.
const looseA: { heading: string } = { heading: 'north' };
const tightB = { heading: 'north' };
console.log(looseA.heading, tightB.heading);

Output


      

satisfies: check without widening

One newer piece of syntax solves a real annoyance. Annotating a value checks it against a type but also replaces the value's inferred type with the annotation, losing detail. satisfies checks without replacing.

Annotation versus satisfies

type Palette = Record<string, string>;

// Annotated: checked, but every key is now just string, so the compiler
// has forgotten which keys exist.
const annotated: Palette = { primary: '#3055d8', danger: '#b4232a' };

// satisfies: checked against Palette, and the exact keys are remembered.
const checked = { primary: '#3055d8', danger: '#b4232a' } satisfies Palette;

console.log(annotated.primary);
console.log(checked.primary);

// The difference shows up in what the compiler will let you ask for.
// checked.primary is known to exist; on `annotated` any string key is allowed,
// including ones that are not there.
console.log(Object.keys(checked));

Output


      

satisfies keeps the compiler honest about keys

TS2339
type Palette = Record<string, string>;

const checked = { primary: '#3055d8', danger: '#b4232a' } satisfies Palette;

// This key does not exist, and because satisfies preserved the exact shape,
// the compiler can say so. With a plain annotation it would have allowed it.
console.log(checked.secondary);

What the compiler says

error TS2339: Property 'secondary' does not exist on type '{ primary: string; danger: string; }'.

Annotations change nothing at run time

Worth demonstrating once, since it follows from lesson 01 and is easy to forget in practice. Open the compiled output below and count the annotations: there are none.

Heavily annotated in, plain JavaScript out

interface Point {
  x: number;
  y: number;
}

function distance(a: Point, b: Point): number {
  const dx: number = a.x - b.x;
  const dy: number = a.y - b.y;
  return Math.sqrt(dx * dx + dy * dy);
}

const origin: Point = { x: 0, y: 0 };
const target: Point = { x: 3, y: 4 };

console.log('distance:', distance(origin, target));
What this compiles to
function distance(a, b) {
    const dx = a.x - b.x;
    const dy = a.y - b.y;
    return Math.sqrt(dx * dx + dy * dy);
}
const origin = { x: 0, y: 0 };
const target = { x: 3, y: 4 };
console.log('distance:', distance(origin, target));

Output


      

Your turn

  1. Write const mode = 'dark'; and a function apply(mode: 'dark' | 'light'). Confirm the call works. Now change const to let and predict the error before checking.
  2. Take this over-annotated line and remove every annotation that earns nothing: const ids: number[] = [1, 2, 3]; const first: number = ids[0]!;
  3. Write const totals = []; then totals.push(1). Run tsc --noEmit and read what it says about the empty array. Then fix it with one annotation.
  4. Extract a .map() callback into a named const and watch TS7006 appear. Fix it by annotating the parameter, and note that you have now written by hand what the compiler was doing for free.
  5. Take an object literal with three specific keys, annotate it with Record<string, number>, and try to read a key that does not exist. Then swap the annotation for satisfies and try again.

Recap

  • The compiler infers almost everything. Annotate the boundaries — parameters, exported signatures, empty containers — and let the interior infer.
  • const infers the exact literal value; let widens to the general type. That difference is behind a whole family of confusing errors.
  • Callback parameters are typed by their context, which is why extracting a callback into a named function suddenly requires an annotation.
  • An annotation that repeats what the compiler already knows costs maintenance and earns nothing. Delete it and see whether anything breaks.
  • Use satisfies when you want a value checked against a type without losing the precision of what you actually wrote.

Saved in this browser only.

Newsletter

New writing, when it lands.

Occasional notes on shipping software with AI agents. No schedule, no filler, unsubscribe in one click.