Inference: when to annotate and when to stop
You have seen types written out. In real TypeScript you write far fewer than you might expect, because
the compiler infers most of them from your values and how they flow. Knowing exactly where
annotation earns its place — and where it is just noise — is what makes typed code read cleanly
instead of drowning in : type everywhere.
What inference does
Give TypeScript a value, and it works out the type:
let city = "Pune"; // inferred: string
const rate = 4.5; // inferred: number
const active = true; // inferred: boolean
const nums = [1, 2, 3]; // inferred: number[]
const book = { title: "Sairat", price: 300 }; // inferred: { title: string; price: number }
None of those has an annotation, yet each has a precise type — hover over any of them in the editor and
TypeScript tells you. It infers from the literal ("Pune" is a string), from array contents, from
object shapes. Not writing a type does not mean there is no type — inference is filling it in, and
the value is just as checked as if you had annotated it.
Inference follows the value through your code, too:
const prices = [120, 340, 90]; // number[]
const doubled = prices.map(p => p * 2); // p inferred number, doubled inferred number[]
const total = prices.reduce((a, b) => a + b, 0); // inferred number
You annotated nothing, and TypeScript knows p is a number, doubled is number[], and total is a
number — because it traced the types through map and reduce. This flow is why idiomatic TypeScript
is barely longer than JavaScript.
let versus const — inference is different
A subtle, important detail: const infers a narrower type than let, because a const cannot
change:
let a = "Pune"; // inferred: string (a could be reassigned to any string)
const b = "Pune"; // inferred: "Pune" (b can never change, so its type is the exact literal)
let a is inferred as string — the general type — because you might reassign a to another string.
const b is inferred as the literal type "Pune" — the exact value — because b can never be
anything else. This literal narrowing (a full lesson soon) is why const is not just a style
preference but affects the types you get, and it becomes powerful with unions.
Where to annotate — and where not to
The rule that runs through the whole course: annotate the boundaries, let inference handle the interior.
Do annotate:
-
Function parameters. TypeScript cannot infer these — it has no idea what a caller will pass — so they are almost always annotated:
function area(radius: number) { // must annotate radius return 3.14159 * radius * radius; // return type inferred as number } -
Function return types of public/exported functions — not because inference fails, but because writing the return type documents the contract and catches an accidental change to what the function returns. For a small internal helper, inferring the return type is fine.
-
A variable declared without an initial value, since there is nothing to infer from:
let result: string; // no value yet — must annotate, or it would be `any` result = compute(); -
External data — an API response,
JSON.parse— where you are asserting a shape TypeScript cannot verify (and should really validate at runtime).
Do not annotate:
- Local variables with an obvious initial value.
const x: number = 5is noise — TypeScript already knowsxis a number. Just writeconst x = 5. - Anything inference already gets right. If hovering shows the type you want, an annotation adds nothing but length and a second place to keep in sync.
Over-annotation is a beginner habit that makes code harder to read and more to maintain (change the value, now you must change the annotation too). The elegant TypeScript you will read in good codebases annotates the edges and stays quiet inside.
When inference gets it wrong (or too wide)
Sometimes inference is not what you want, and an annotation guides it:
const status = "paid"; // inferred "paid" (literal) — often fine
let mode = "dark"; // inferred string — but you wanted a specific union
// annotate to get the type you actually mean:
let theme: "light" | "dark" = "dark"; // now only those two values are allowed
And the empty-array trap, which catches everyone:
const items = []; // inferred: any[] — TypeScript has nothing to infer from!
items.push("Pune");
items.push(42); // no error — items is any[], the safety is gone
const items2: string[] = []; // annotate when the array starts empty
items2.push(42); // error — now it is checked
An empty array with no annotation infers any[], silently losing safety. Annotate an array that
starts empty. This is the one place where "let inference handle it" fails, and knowing it saves a
real bug.
The mental model
Think of annotation as specifying a contract or correcting a guess, not as stating the obvious. Annotate where TypeScript cannot know (parameters, uninitialised variables, external data) or where you want a narrower type than it inferred (a specific union, an empty array). Everywhere else, trust inference — it is precise, it stays in sync automatically, and it keeps your code clean. Good TypeScript looks surprisingly like good JavaScript, with types only where they add information the compiler could not have worked out itself.
Check your work
What inference does. Works out a value's type from its literal, contents, or shape — and follows
it through operations like map and reduce.
Does no annotation mean no type? No — inference fills it in, and the value is fully checked.
let versus const inference. let infers the general type (string); const infers the exact
literal ("Pune"), because it cannot change.
Where you must annotate. Function parameters (inference cannot guess them), and a variable with no initial value.
Where annotation is good practice. Return types of public functions (documents the contract) and external data.
Where not to annotate. Locals with an obvious value, and anything inference already gets right — over-annotation is noise.
The empty-array trap. const items = [] infers any[]; annotate an array that starts empty.
The mental model. Annotate to specify a contract or correct a guess, not to state the obvious.
Practice
- Declare five values with no annotations and hover to confirm the inferred types.
- Compare
let a = "Pune"andconst b = "Pune". Noteaisstringandbis"Pune". Explain why. - Write a function, annotate only the parameter, and confirm the return type is inferred.
- Over-annotate a function (a type on every local) then strip the unnecessary annotations. Decide which reads better.
- Declare
const items = [], push a string and a number, and confirm both are allowed. Then annotate itstring[]and watch the number be rejected. - Declare
let mode = "dark"(inferredstring) thenlet theme: "light" | "dark" = "dark". Try assigning"blue"to each and compare. - Take a function whose inferred return type you are unsure of, add an explicit return type, and see whether TypeScript agrees.
Official documentation
- TypeScript — Type Inference — How types are worked out from values and flow.
- TypeScript — Everyday Types: type annotations on variables — When to write one.
- TypeScript — const assertions — Related to
constliteral inference (next lesson).
Next: literal types and as const.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.
About the internship