Lesson 04 / 23
Type Inference
The compiler's rules for inferring types from an initial value, context, and return expressions; widening behavior and where a type annotation is still required.
Contents
In the previous lessons’ examples, the compiler already knew many types that were
never written: the types of fields after narrowing, the type of array elements, the
parameters of a reduce callback. Where did this knowledge come from?
The answer is type inference: the compiler computes an expression’s type from the information around it. Inference is what makes TypeScript usable — if every variable needed a written type, the type layer’s cost would exceed its benefit. This lesson covers the rules of inference, its limits, and the points where a type annotation is still required.
Seeing the Inferred Type
The most direct way to examine inference is to ask the compiler to produce a declaration file. A declaration file writes the types of the names a module exports; inferred types show up there explicitly.
export const unit = "C"; export let variable = "C"; export const value = 21.4; export const measurement = { id: "s-01", value: 21.4, unit: "C" }; export const values = [21.4, 22.1]; export const mixed = [21.4, "none"]; export function average(measurements: number[]) { return measurements.reduce((t, d) => t + d, 0) / measurements.length; }
Saved under the name inference.ts and compiled with
tsc --strict --target es2022 --declaration --emitDeclarationOnly inference.ts, an
inference.d.ts is written next to it:
export declare const unit = "C"; export declare let variable: string; export declare const value = 21.4; export declare const measurement: { id: string; value: number; unit: string; }; export declare const values: number[]; export declare const mixed: (string | number)[]; export declare function average(measurements: number[]): number;
This file shows six rules of inference in a single glance. They are taken in order below.
Inference From the Initial Value, and Widening
The type of constant unit is not string but "C" — a set containing only that one
value. The type of constant value is likewise not number but 21.4. The
declaration of variable, by contrast, has its type widened to string.
The rule is this: a declaration whose value can change cannot hold a literal type,
because the declaration can later take another value of the same type. After the
declaration let variable = "C", the statement variable = "Pa" can be written; if
the type stayed "C", this assignment would be rejected. A declaration whose value
cannot change has no need to widen.
The same reasoning holds for object fields. Although the constant measurement is
declared with const, the unit field’s type is inferred as string, not "C". The
reason is the distinction established in the JavaScript Fundamentals course: a const
declaration fixes the binding, not the object’s content. The expression
measurement.unit = "Pa" is a valid JavaScript statement, so the field’s type is
widened.
This behavior has a direct consequence:
type Unit = "C" | "Pa" | "%"; type Measurement = { id: string; value: number; unit: Unit }; const raw = { id: "s-01", value: 21.4, unit: "C" }; const record: Measurement = raw; console.log(record);
d4.ts(5,7): error TS2322: Type '{ id: string; value: number; unit: string; }' is not assignable to type 'Measurement'.
Types of property 'unit' are incompatible.
Type 'string' is not assignable to type 'Unit'.
The assignment fails because the type of raw.unit has widened to string, and
string does not fit the three-valued Unit set. Preventing widening is the subject
of the Const Types and as const lesson.
For arrays, inference takes the union of the elements’ types: number[] for values,
(string | number)[] for the mixed-element mixed. Because an array is mutable too,
element types are widened as well.
Contextual Typing
Inference is not one-directional. An expression’s type can also come from where the expression is expected. This is called contextual typing:
const values: number[] = [21.4, 22.1, 23.0]; values.forEach((v) => console.log(v.toFixed(1))); values.forEach((v) => console.log(v.toUpperCase()));
d1.ts(4,37): error TS2339: Property 'toUpperCase' does not exist on type 'number'.
No type is written on parameter v. The compiler takes the type from what the
forEach method’s callback expects: since values is a number[], the callback’s
first parameter is number. The third line is valid; the fourth is rejected because
it calls a string method.
Contextual typing also explains why the implicit-any check does not trigger on
callbacks: even though the parameter looks untyped, it has a type coming from context.
Return Type Inference and Where the Error Lands
Function return types are inferred too. In the declaration file above, the average
function’s return type shows up as number even though it was never written.
Inference’s convenience has a cost, and that cost shows up in where the error is reported. First, the version with no return type written:
function createMeasurement(id: string, raw: string) { return { id, value: raw }; } const record = createMeasurement("s-01", "21.4"); const rounded: string = record.value.toFixed(1); console.log(rounded);
d2.ts(6,38): error TS2551: Property 'toFixed' does not exist on type 'string'. Did you mean 'fixed'?
The error is reported on the sixth line, that is, where the function is used. Yet
what is actually wrong is on the second line: a string was placed in the value
field. The same function, written together with the model’s type:
type Measurement = { id: string; value: number }; function createMeasurement(id: string, raw: string): Measurement { return { id, value: raw }; } console.log(createMeasurement("s-01", "21.4"));
d3.ts(4,16): error TS2322: Type 'string' is not assignable to type 'number'.
The diagnostic is now on the fourth line — at the error’s source. The difference looks trivial for a single function; in a call chain running through dozens of modules, it determines how long it takes to find the error’s source.
A practical rule follows from this: write types at the boundaries a module exposes, leave internal detail to inference. The parameter and return types of a module’s exported functions, a class’s public methods, and a module’s data types are written by hand. Local variables, callback parameters, and private helper functions are left to inference.
The second reason for this rule is that a type annotation is a contract. A written return type tests, when the body changes, whether the contract has been broken. An inferred return type changes silently along with the body — and the fact that the contract changed is only noticed once an error appears in the code that uses it.
The Limits of Inference
Inference cannot fill every gap. There are three typical situations.
An accumulator starting from an empty container. For a local declaration const valid = [], the compiler gathers the element type from later additions. This, again,
means the error is carried far away:
type Measurement = { id: string; value: number }; function filter(records: Measurement[]): Measurement[] { const valid = []; for (const r of records) { if (r.value > 0) { valid.push(r.id); } } return valid; } console.log(filter([{ id: "s-01", value: 21.4 }]));
empty2.ts(10,3): error TS2322: Type 'string[]' is not assignable to type 'Measurement[]'. Type 'string' is not assignable to type 'Measurement'.
What is wrong is the seventh line — the record’s id was pushed instead of the record
itself — but the diagnostic appears on the tenth. Had it been written as const valid: Measurement[] = [], the diagnostic would have shown up on the line where the push
happens.
Data coming from outside. The return type of a JSON.parse call is any; there
is no information for inference to use. The rule established in the previous lesson
applies here — the return is received as unknown and checked.
Overly wide inference. The type inferred from an object literal is usually looser
than intended; the unit: string example above is the typical case. When the intended
type is written, the compiler both narrows it and checks that the literal fits that
type.
Summary
- Type inference computes an expression’s type from its initial value, its context, and return expressions; inferred types can be viewed by producing a declaration file.
- Literal types widen in declarations whose value can change; a
constdeclaration fixes the binding, so its own literal type is kept, but object fields still widen. - Contextual typing takes an expression’s type from where it is expected; callback parameters are typed this way.
- When the return type is not written, the error is reported where the function is used; when it is written, it is reported at the source.
- Types are written at exposed boundaries, internal detail is left to inference; a type annotation is also a contract tested when the body changes.
Next Step
The measurement record’s unit field could take one of three values, and that was
written by combining types that are alternatives to one another. The same operation
also has an intersection direction: values that carry the requirements of two types at
once. The next lesson covers the rules of union and intersection types, which
operations can be done on a union, and when an intersection collapses to nothing.
To keep your progress and take notes, Log in
My notes
Log in to take notes.