Type Inference: Letting the Compiler Work for You

Beginner
9 min

Type Inference: Letting the Compiler Work for You

TypeScript does not require you to annotate every variable. In most code the compiler works out the type from the value you assign, the function you call, or the place an expression is used. After this lesson you will know exactly when TypeScript infers a type, when it widens a literal, how contextual typing types callbacks, and where an explicit annotation is still the right choice.

Inference From Initial Values

When a variable is declared with an initializer, its type is inferred from that initializer. The variable keeps that type for its entire life:

typescript
let count = 10; // number let title = "Report"; // string let ready = false; // boolean let scores = [90, 85]; // number[] count = "ten"; // Error: Type 'string' is not assignable to type 'number'

Hover over a variable in VS Code and the editor shows the inferred type. Writing let count: number = 10 is legal but redundant; teams usually leave it out. When an array holds mixed values, the element type becomes a union: [1, "two", null] is (string | number | null)[].

A variable declared without an initializer is typed any unless you annotate it, and noImplicitAny (part of strict) will report it. Always give such variables an explicit type.

Literal Types and Widening

const and let infer differently for primitive values:

typescript
const status = "active"; // type: "active" (a string literal type) let mode = "dark"; // type: string (widened)

Because a const can never be reassigned, TypeScript keeps the exact literal type. A let may be reassigned, so the literal is widened to its base type. The same rule applies to numbers and booleans.

The as const assertion stops widening inside objects and arrays and marks every property readonly:

typescript
const roles = ["admin", "editor", "viewer"] as const; // type: readonly ["admin", "editor", "viewer"] type Role = (typeof roles)[number]; // "admin" | "editor" | "viewer"

This pattern is the standard way to derive a union type from a runtime list, and you will use it constantly.

Return Type Inference

Function return types are inferred from the return statements. A function with several returns gets a union:

typescript
function parsePort(raw: string) { const n = Number(raw); if (Number.isNaN(n)) return null; return n; } // inferred: (raw: string) => number | null

Inference here is convenient, but exported or public functions benefit from explicit return types: they document intent and stop an accidental change in the body from silently changing the API.

Contextual Typing

Inference also flows into expressions from their surroundings. Callback parameters are typed from the function they are passed to, so you rarely annotate them:

typescript
const users = [{ name: "Ada", age: 36 }, { name: "Linus", age: 55 }]; const adults = users.filter((u) => u.age >= 18); // u is inferred as { name: string; age: number } window.addEventListener("click", (event) => { console.log(event.clientX); // event is MouseEvent });

This is called contextual typing. It also applies to object literals assigned to a typed variable.

satisfies: Check Without Widening

Sometimes you want to verify that a value matches a type but keep the more precise inferred type. The satisfies operator does exactly that:

typescript
type Theme = Record<string, string | [number, number, number]>; const palette = { primary: "#0055ff", accent: [255, 128, 0], } satisfies Theme; palette.primary.toUpperCase(); // OK: still known to be a string palette.accent[0]; // OK: still known to be a tuple

With a plain annotation (const palette: Theme = ...) both properties would collapse to string | [number, number, number] and the method calls would fail.

When to Annotate Explicitly

| Situation | Recommendation | |---|---| | Local variable with an initializer | Let inference work | | Function parameters | Always annotate (no inference source) | | Exported / public function return types | Annotate for stability | | Empty array literal [] | Annotate, otherwise it starts as any[] | | Variables assigned later | Annotate at declaration | | Object that must match a shape but keep detail | Use satisfies |

Common mistakes

  • Declaring const list = [] and pushing later. Write const list: string[] = [] instead.
  • Annotating everything "to be safe". Redundant annotations add noise and can be wrong.
  • Expecting let x = "a" to be the literal "a". Use const or as const when the literal matters.
Quick Quiz
Question 1 of 3

What is the inferred type of `const kind = "circle";`?

Key Takeaways

  • TypeScript infers variable types from initializers and function return types from return statements.
  • const keeps literal types; let widens them. as const freezes literals inside objects and arrays.
  • Callback parameters are contextually typed from the function they are passed to.
  • satisfies checks a value against a type without losing the precise inferred type.
  • Always annotate function parameters, public return types, and variables without initializers.

Next lesson: Functions and Type Annotations — parameter types, optional and default parameters, and typing function values.

Type Inference: Letting the Compiler Work for You - TypeScript | CodeYourCraft | CodeYourCraft