Type Inference
You write fewer types than you think
Section titled “You write fewer types than you think”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: numberconst 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.
let widens, const keeps the literal
Section titled “let widens, const keeps the literal”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 TS2345const method = "GET"; // type: "GET"let method2 = "GET"; // type: string
function req(m: "GET" | "POST") {}req(method); // ✅ "GET" fits the unionreq(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)"]
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.
Contextual typing: inference flows inward
Section titled “Contextual typing: inference flows inward”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.
Best common type
Section titled “Best common type”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.
When to annotate
Section titled “When to annotate”Let inference do the work, and annotate deliberately in three places:
- Function parameters — inference can’t read your mind about inputs (though contextual typing covers callbacks).
- 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.)
- 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.
let vs const widening, completely
Section titled “let vs const widening, completely”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:
| Declaration | Value | Inferred type | Why |
|---|---|---|---|
const a = "hi" | string literal | "hi" | a itself can never be reassigned |
let b = "hi" | string literal | string | b 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 literal | number[] | same reason — array elements are mutable even though arr can’t be reassigned |
const lit = "hi" as const | const 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 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: numberThis 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 TS2532function 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 TS2322function 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.
Module vs. script scope, in full
Section titled “Module vs. script scope, in full”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 TS2451const 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 collisionA 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.
Two inference topics that need generics
Section titled “Two inference topics that need generics”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.
Mental Model
Section titled “Mental Model”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"]
Trade-offs
Section titled “Trade-offs”| Choice | Benefit | Cost |
|---|---|---|
| Inferred return type (no annotation) | Less to write; return type updates automatically as the function evolves | A widening change (a new return; with no value) reports its error at every call site instead of at the function |
| Annotated return type | A behavior-changing edit is caught at the function itself, immediately | You must update the annotation yourself on an intentional change — one extra line to maintain |
const obj = {...} relying on inferred property types | Less ceremony for everyday objects | Properties still widen — if you need a literal property type, const alone does not give it to you |
as const | Locks the literal recursively through the whole value, not just the top-level binding | Makes the value (and everything in it) readonly, which is not always what you want |
Script-scope file (no import/export) | None, really, for application code | Shares global scope with DOM lib ambients (name, top, length, …) — a silent collision trap |
Common Misconceptions
Section titled “Common Misconceptions”constmeans 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 forobj.xreflects 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
.tsfile risks thename/top/lengthcollision. Only files with noimport/exportat all (true scripts) do. The instant a file has a realimportorexportstatement, it’s a module with its own scope —export {}is the same fix, spelled out for a file that has neither yet.
Reading AI-generated code
Section titled “Reading AI-generated code”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 withas 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.