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 label = "Ada"; // inferred: "Ada" (a literal type!)
const items = [1, 2]; // inferred: number[]

Note that count and label 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:

// @expect-error TS2345
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<br/>#quot;GET#quot;"] -->|const: can't change| keep["type: #quot;GET#quot;<br/>(narrow)"]
  lit -->|let: might change| wide["type: string<br/>(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:

interface Circle { kind: "circle"; radius: number }
interface Square { kind: "square"; side: number }
declare const circle: Circle, square: Square;
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.

The rule “const keeps the literal, let widens” is true, but it only applies to the binding itself — not to everything reachable through it. This table covers every shape that actually comes up:

DeclarationValueInferred typeWhy
const a = "hi"string literal"hi"a itself can never be reassigned
let b = "hi"string literalstringb might be reassigned to any string later
const obj = { x: "hi" }object literal{ x: string } — not { x: "hi" }const only protects the binding obj; obj.x is a mutable property and still widens
const arr = [1, 2, 3]array literalnumber[]same reason — array elements are mutable even though arr can’t be reassigned
const lit = "hi" as constconst assertion"hi"as const opts the value (not just the binding) out of widening, recursively

The middle two rows are the one genuinely surprising part: const is a property of the variable, not a deep-freeze of the value. obj.x = "bye" is legal (nothing stops it), so the compiler is right to infer the wider string for obj.x even though obj was declared with const.

const protects the binding, not nested properties

type Equal<A, B> = (<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? true : false;
const obj = { x: "hi" };
type ObjX = typeof obj.x;
const check1: Equal<ObjX, string> = true; // ✅ widened to string, not "hi"
const check2: Equal<ObjX, "hi"> = false; // ✅ confirms it's NOT the literal
const arr = [1, 2, 3];
type ArrElem = typeof arr[number];
const check3: Equal<ArrElem, number> = true; // ✅ widened to number, not 1 | 2 | 3
Open in TS Playground

Click Check types — zero errors confirms all three Equal checks pass: obj.x really is string, not "hi", despite obj being declared const.

Return-type inference vs. annotation: a refactor that changes behavior silently

Section titled “Return-type inference vs. annotation: a refactor that changes behavior silently”

Inference on a public return type has a real, provable failure mode: a small internal change can widen what a function returns without producing any error at the function itself — the break shows up somewhere else entirely.

function parseAmount(input: string) {
return Number(input);
}
const total = parseAmount("42").toFixed(2); // ✅ inferred return type: number

This compiles clean — parseAmount is inferred to return number, and .toFixed is a valid number method. Now a teammate adds a guard for invalid input, a perfectly reasonable change:

// @expect-error TS2532
function parseAmount(input: string) {
const n = Number(input);
if (Number.isNaN(n)) return;
return n;
}
const total = parseAmount("42").toFixed(2);
error TS2532: Object is possibly 'undefined'.

parseAmount itself still compiles with zero complaints — TypeScript infers its new, honest return type, number | undefined, and moves on. The error shows up at the call site, possibly in a different file, possibly written by someone who has never looked at parseAmount’s body. Annotate the return type instead, and the exact same edit is caught immediately, at the actual mistake:

// @expect-error TS2322
function parseAmount(input: string): number {
const n = Number(input);
if (Number.isNaN(n)) return;
return n;
}

Type 'undefined' is not assignable to type 'number' fires on the return; line itself — the moment the change was made, in the function that changed, instead of downstream in code that had every reason to assume nothing changed. This is the concrete version of “annotate public return types”: the annotation doesn’t make the function more correct, it moves where an incorrect change gets reported — from an arbitrary caller, potentially much later, back to the line that actually broke the contract.

The label/name collision from the top of this lesson is a symptom of a real, general rule worth stating precisely: a file with no top-level import or export is a script, and a script shares its scope with the DOM lib’s ambient globals. name, top, length, event, and close are all real Window properties — declaring any of them at script scope collides:

// @expect-error TS2339 TS2451
const name = "Ada";
console.log(name.toUpperCase());

Cannot redeclare block-scoped variable 'name' is the first error; because the redeclaration fails, name resolves to the ambient Window.name binding instead (typed as effectively unusable here), which is why a second, seemingly unrelated error (TS2339, no toUpperCase on the resulting type) follows right behind it. The fix is the one every real-world .ts file already has, usually without anyone thinking about it:

export {};
const name = "Ada";
console.log(name.toUpperCase()); // ✅ this file is now a module — its own scope, no collision

A bare export {} exports nothing, but it’s enough to make the file a module rather than a script — modules get their own scope and never see the ambient globals unless they explicitly reference window.name. Every file with a real import or export already gets this for free; export {} is the one-line fix for a file that otherwise has neither.

Callback inference in object literals (TypeScript 6.0) and blocking an inference site with NoInfer<T> (5.4) are inference topics, but both need generics (<T>) to make sense. They live at the end of generics/inference-and-infer — come back to them after the Generics module.

Inference is doing exactly one job everywhere in this lesson: computing the widest type consistent with everything the compiler has actually seen. let sees “might be reassigned” and widens; a property on a const object sees “might be mutated” and widens for the same reason, regardless of the binding’s own const-ness; a function with no return annotation sees “every value this could return, today” and infers the union — which grows the moment a new code path is added, with nothing to flag that growth except wherever the wider result gets used. An annotation is you overriding that computation with a promise the compiler will now check against, rather than derive.

flowchart TB
  seen["What the compiler has
actually seen so far"] --> widest["Widest type
consistent with that"]
  widest -->|"no annotation"| silent["Grows silently when
the code changes"]
  widest -->|"explicit annotation"| checked["Compiler checks NEW code
against the OLD promise"]
Inference computes the widest type consistent with what it has seen
ChoiceBenefitCost
Inferred return type (no annotation)Less to write; return type updates automatically as the function evolvesA widening change (a new return; with no value) reports its error at every call site instead of at the function
Annotated return typeA behavior-changing edit is caught at the function itself, immediatelyYou must update the annotation yourself on an intentional change — one extra line to maintain
const obj = {...} relying on inferred property typesLess ceremony for everyday objectsProperties still widen — if you need a literal property type, const alone does not give it to you
as constLocks the literal recursively through the whole value, not just the top-level bindingMakes the value (and everything in it) readonly, which is not always what you want
Script-scope file (no import/export)None, really, for application codeShares global scope with DOM lib ambients (name, top, length, …) — a silent collision trap
  • const means the whole value is frozen. It only means the binding can’t be reassigned. const obj = { x: 1 }; obj.x = 2; is completely legal, and the compiler’s inferred type for obj.x reflects that by widening, not narrowing to a literal.
  • An inferred return type is just as safe as an annotated one. They behave identically until the function’s body changes. The moment it does, an inferred return type silently changes what callers are promised, while an annotated one turns the same change into an error at the function itself.
  • Every .ts file risks the name/top/length collision. Only files with no import/export at all (true scripts) do. The instant a file has a real import or export statement, it’s a module with its own scope — export {} is the same fix, spelled out for a file that has neither yet.

Generated code frequently omits return-type annotations on exported functions, which hides exactly the failure mode this lesson just proved. When reviewing a diff that adds a new early return, a new thrown error path, or an ?? undefined fallback inside an existing function, check:

  • Does the function have an explicit return type? If not, did the diff’s author (or reviewer) actually re-derive what the function returns now, or just trust that “it still compiles” means “nothing changed”?
  • Search for every call site of a changed function. Do any of them call a method on the result with no null/undefined check — exactly the shape that breaks when a return type silently grows a | undefined?
  • Is a const-declared object’s property being treated as a literal type somewhere downstream, when nothing actually locked it with as const?

🐛 Spot the bug

function getDiscount(code: string) {
const discounts: Record<string, number> = { SAVE10: 0.1 };
if (!(code in discounts)) return;
return discounts[code];
}
function applyDiscount(price: number, code: string): number {
const discount = getDiscount(code)!;
return price * (1 - discount);
}
console.log(applyDiscount(100, "INVALID"));
Show the answer

This compiles with zero errors and produces NaN instead of a thrown error or a sensible fallback. getDiscount has no return-type annotation, so its inferred type silently became number | undefined the moment the if (!(code in discounts)) return; guard was added. The compiler did correctly flag the resulting arithmetic on a possibly-undefined value inside applyDiscount — but the ! right after getDiscount(code) silences exactly that warning (this is the assertions lesson from Why TypeScript, showing up again here), so nothing is left to catch it. discount really is undefined for "INVALID", 1 - undefined is NaN, and price * NaN is NaN, with no exception anywhere. Annotating getDiscount(code: string): number would have surfaced this as a compile error on getDiscount’s own return; line the moment the guard was added — the same mechanism as the parseAmount example earlier in this lesson — instead of relying on nobody reaching for ! to make the (correct) downstream warning go away.

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]?
What is ObjX, and why?
const obj = { x: "hi" };
type ObjX = typeof obj.x;
Where does the resulting compile error appear, and why is that significant?
function parseAmount(input: string) {
  const n = Number(input);
  if (Number.isNaN(n)) return;
  return n;
}
const total = parseAmount("42").toFixed(2);
Why does const name = "Ada"; at the top level of a script-scope .ts file (no import/export) fail to compile?