RizTech Academy logo
RizTech Academy
The Basic TypesLesson 5 of 625 min

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 = 5 is noise — TypeScript already knows x is a number. Just write const 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

  1. Declare five values with no annotations and hover to confirm the inferred types.
  2. Compare let a = "Pune" and const b = "Pune". Note a is string and b is "Pune". Explain why.
  3. Write a function, annotate only the parameter, and confirm the return type is inferred.
  4. Over-annotate a function (a type on every local) then strip the unnecessary annotations. Decide which reads better.
  5. Declare const items = [], push a string and a number, and confirm both are allowed. Then annotate it string[] and watch the number be rejected.
  6. Declare let mode = "dark" (inferred string) then let theme: "light" | "dark" = "dark". Try assigning "blue" to each and compare.
  7. Take a function whose inferred return type you are unsure of, add an explicit return type, and see whether TypeScript agrees.

Official documentation

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