Skip to content

Type Inference

Good TypeScript is not covered in annotations. The compiler infers most types, and fighting it with redundant annotations is a beginner tell. The skill is knowing what it infers and where you actually need to help.

let count = 5; // inferred: number
const name = "Ada"; // inferred: "Ada" (a literal type!)
const items = [1, 2]; // inferred: number[]

Note that count and name are inferred differently, and that difference is one of the most important rules in the language.

A const can never be reassigned, so the compiler keeps its narrowest type — the literal. A let can be reassigned, so the compiler widens to the general type.

const a = "hello"; // type: "hello" (literal — it can't change)
let b = "hello"; // type: string (widened — b might become any string)

This is why literal types “appear and disappear.” It matters constantly:

const method = "GET"; // type: "GET"
let method2 = "GET"; // type: string
function req(m: "GET" | "POST") {}
req(method); // ✅ "GET" fits the union
req(method2); // ❌ string is too wide for "GET" | "POST"
flowchart LR
  lit["literal value
"GET""] -->|const: can't change| keep["type: "GET"
(narrow)"]
  lit -->|let: might change| wide["type: string
(widened)"]
const keeps the literal; let widens

When you want a literal type from a let or an object property, use a const assertion (as const) — a whole topic in the next module.

Inference isn’t only bottom-up (from a value to its type). It also flows top-down from the surrounding context — this is contextual typing.

const nums = [1, 2, 3];
nums.forEach((n) => {
// n is inferred as number — you didn't annotate it.
// The context (an array of number) told the compiler what n must be.
console.log(n.toFixed(2));
});
window.addEventListener("click", (e) => {
// e is inferred as MouseEvent from the event name — pure contextual typing.
});

This is why callback parameters usually don’t need annotations: the function you’re passing them to already declares their types, and inference flows in.

When a value could be several types — like an array with mixed elements — the compiler computes a best common type that covers them all:

const mixed = [1, "two", 3]; // inferred: (string | number)[]
const shapes = [circle, square]; // inferred: (Circle | Square)[]

It doesn’t pick one and error on the rest; it forms the union that fits every element. Occasionally the best common type is wider than you want, which is a signal to annotate explicitly.

Let inference do the work, and annotate deliberately in three places:

  1. Function parameters — inference can’t read your mind about inputs (though contextual typing covers callbacks).
  2. Function return types on public APIs — annotating the return type catches mistakes inside the function and documents intent. (Inference works, but an explicit return type is a guardrail.)
  3. When inference is wider than you want — e.g. you need a literal or a specific union, not the widened type.

Everywhere else, prefer inference. Redundant annotations are noise that can drift from reality during refactors.

What type is inferred for `const x = "GET"`?
Why does `let y = "GET"` infer `string` while `const x = "GET"` infers `"GET"`?
Why does a callback parameter often not need a type annotation?
What does TypeScript infer for `[1, "two", 3]`?