Skip to content
academia.sh

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 const declaration 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.

Start typing to search.

↑↓ Esc navigate · open · close