Type inference can look like mind reading. Write const total = 1 + 2, and the editor knows that total is a number. Pass a callback to map, and its parameter acquires a type even though no annotation appears beside it. Check typeof value === "string", and an earlier union becomes a string inside one branch.

There is no guesswork in any of these cases. A type checker collects evidence from syntax and context, translates that evidence into constraints, and computes a type that satisfies the language’s rules. What makes TypeScript interesting is that it combines several inference strategies rather than implementing one pristine academic algorithm. It has local inference, contextual typing, generic type-parameter inference, literal widening, structural subtyping, and a control-flow analyzer. Understanding where each mechanism applies makes compiler messages less mysterious and API design much more deliberate.

From syntax to a constraint problem

At the simplest level, an expression synthesizes a type from its form. A numeric literal suggests a numeric literal type, a function call consults the callee’s signatures, and an object literal contributes properties whose values are inferred recursively. The checker records relationships such as “the argument type must be assignable to the parameter type” rather than enumerating every possible type in the program.

Consider a deliberately small inference language. Let alpha be an unknown type and let <: mean “is assignable to.” Calling an identity-like function with a string can produce this constraint set:

C={"north"<:α}C = \{\texttt{"north"} <: \alpha\}

The solver chooses a substitution S for the unknown:

S(α)=stringS(\alpha) = \texttt{string}

Whether it preserves the literal type "north" or widens it to string depends on where the unknown appears and whether the value may later be mutated. That policy is not a mathematical necessity; it is a language-design choice intended to keep ordinary code usable.

A toy constraint collector and solver might look like this:

text
infer(expression, expectedType?):
  if expression is literal:
    return freshLiteralType(expression)

  if expression is variable:
    return environment.lookup(expression.name)

  if expression is call(callee, arguments):
    signature = chooseCandidate(infer(callee), arguments)
    for each (argument, parameter) in pair(arguments, signature.parameters):
      argumentType = infer(argument, parameter.type)
      addConstraint(argumentType, ASSIGNABLE_TO, parameter.type)
    return substitute(signature.returnType, solveConstraints())

solveConstraints():
  gather lower and upper bounds for each type variable
  merge compatible candidates
  reject contradictory bounds
  apply defaults for unresolved variables
  return substitutions

Real TypeScript is considerably more involved: overload candidates are tested, conditional types may be deferred, and inference has priorities and fixed candidates. Still, “collect evidence, form constraints, solve, then check” is the useful mental model.

flowchart LR A[Parse expression] --> B[Create fresh type variables] B --> C[Collect local evidence] C --> D[Apply contextual expectations] D --> E[Build lower and upper bounds] E --> F{Constraints compatible?} F -- yes --> G[Choose substitutions] G --> H[Widen or preserve literals] H --> I[Check assignability] F -- no --> J[Report diagnostic]

Note

Inference and checking are related but distinct. The checker may infer a candidate type successfully and then reject the program because that candidate is not assignable where it is used.

Local inference and contextual typing move in opposite directions

Local inference usually flows upward from an expression. The initializer determines the variable type, and argument expressions contribute candidates for generic parameters. This is sometimes called synthesis: the expression answers, “What type do I have?”

ts
const port = 8080; // 8080, preserved because the binding cannot change
let retries = 3; // number, widened because the binding can change

const server = {
  host: "localhost",
  port,
};
// { host: string; port: number }

Contextual typing flows downward. A known destination tells an expression what type it is expected to have. The parameter of the arrow function below is not inferred from its body; it comes from the callback position in Array.prototype.map.

ts
const lengths = ["Ada", "Grace"].map((name) => name.length);
// name: string; lengths: number[]

type Handler = (event: { kind: "save"; path: string }) => void;

const onSave: Handler = (event) => {
  console.log(event.path); // event is contextually typed
};

This two-way exchange resembles bidirectional type checking: some expressions synthesize types, while others are checked against expected types. TypeScript is not a textbook bidirectional calculus, but the model explains why moving the same callback out of context changes the result.

ts
const detached = (value) => value.length;
// Error under noImplicitAny: parameter 'value' implicitly has type 'any'.

const attached: (value: string) => number = (value) => value.length;

The body alone does not prove that value is a string. Any object with a numeric length would work, and TypeScript deliberately does not search the universe of structural types to invent the most abstract parameter. An annotation or a call-site context supplies the missing direction.

Situation Evidence direction Typical result
const x = expression Expression to binding Type synthesized from initializer
Callback argument Parameter to callback Parameters receive contextual types
Generic call Arguments to type parameters Candidates combined into substitutions
Return annotation Signature to function body Returned expressions checked against target
satisfies expression Expression plus target check Inferred type retained, compatibility checked

Constraints and unification are useful, but assignability changes the game

In Hindley-Milner-style inference, unification tries to make two type expressions equal. Unifying Array<alpha> with Array<string> yields alpha = string. The classic algorithm recursively decomposes constructors, binds variables, and performs an occurs check so that an unknown cannot contain itself.

text
unify(left, right):
  left = applySubstitutions(left)
  right = applySubstitutions(right)

  if left equals right: return
  if left is type variable and left does not occur in right: bind(left, right)
  else if right is type variable and right does not occur in left: bind(right, left)
  else if constructors match:
    unify each corresponding type argument
  else:
    fail with incompatible types

TypeScript cannot rely on equality alone. Its structural type system constantly asks whether one type is assignable to another. An object may have extra properties, unions introduce several possible constituents, and function parameters and returns impose bounds in different directions. Instead of solving only alpha = T, the checker often accumulates lower candidates and upper constraints.

ts
function choose<T>(left: T, right: T): T {
  return Math.random() < 0.5 ? left : right;
}

const value = choose({ id: 1 }, { id: 2, label: "two" });
// T must accommodate both argument types.

Candidate combination is sensitive to context and compiler version; it is not a promise to compute a unique mathematical “best” type. The important point is that both arguments constrain the same T. The resulting object type must be safe for what the function claims to return.

Warning

Do not use an inferred type shown by one TypeScript release as a serialization format or public contract. Inference heuristics can improve between releases while explicitly annotated API types remain stable.

Widening decides whether literals are facts or possibilities

The literal "ready" can mean exactly one value or stand for any future string. TypeScript uses mutability and context to choose between those interpretations. A const variable preserves the primitive literal, while an object property is generally mutable even when the object binding is const, so its value widens.

ts
const status = "ready"; // "ready"
let mutableStatus = "ready"; // string

const job = { status: "ready" }; // { status: string }
const frozenJob = { status: "ready" } as const;
// { readonly status: "ready" }

Widening prevents accidental over-specificity. If let mutableStatus had type "ready", assigning "running" on the next line would fail even though mutation is the reason let exists. Conversely, discriminated unions need literal discriminants, so as const, an explicit annotation, or contextual typing can preserve them.

The satisfies operator separates inference from validation. It checks a value against a target without replacing the value’s inferred type with that target.

ts
type RouteTable = Record<string, { method: "GET" | "POST"; path: string }>;

const routes = {
  health: { method: "GET", path: "/health" },
  submit: { method: "POST", path: "/submit" },
} satisfies RouteTable;

routes.health.method; // "GET"
routes.missing; // Error: no such inferred property

An annotation such as const routes: RouteTable = ... exposes only the broad Record view, so arbitrary string lookup is allowed and known keys lose precision. satisfies checks the contract while retaining the concrete key set.

Arrays face a related “best common type” problem. For [1, "two"], the checker seeks a type to which every element is assignable, producing (string | number)[]. A tuple context or as const instead preserves positions and literals as readonly [1, "two"].

Generic inference solves relationships, not placeholders

A generic parameter is valuable when it connects multiple positions. In first<T>(items: readonly T[]): T | undefined, the argument contributes a candidate for T, and the chosen substitution flows into the return type.

ts
function first<T>(items: readonly T[]): T | undefined {
  return items[0];
}

const firstName = first(["Ada", "Grace"]); // string | undefined

function getProperty<ObjectType, Key extends keyof ObjectType>(
  object: ObjectType,
  key: Key,
): ObjectType[Key] {
  return object[key];
}

const user = { id: 42, name: "Lin" };
const userName = getProperty(user, "name"); // string
// getProperty(user, "email"); // Error: "email" is not keyof typeof user

The constraint Key extends keyof ObjectType is an upper bound: inferred keys must belong to the object’s key union. The return type then performs an indexed access with the inferred key. This is more than filling blanks independently; the solver preserves a relationship across argument and result positions.

Inference can fail when evidence points in competing directions or appears only in a return position. TypeScript cannot infer T from an uncontextualized call to make<T>(): T, because no argument provides a candidate. A default or explicit type argument is honest:

ts
function makeSet<Element = string>(): Set<Element> {
  return new Set<Element>();
}

const names = makeSet(); // Set<string>
const ids = makeSet<number>(); // Set<number>

Conditional types with infer use a different but related mechanism: they pattern-match a type and introduce an unknown inside the matched structure.

ts
type AwaitedValue<Value> = Value extends PromiseLike<infer Inner>
  ? AwaitedValue<Inner>
  : Value;

type Result = AwaitedValue<Promise<Promise<number>>>; // number

Flow narrowing and variance add time and direction

An inferred declared type is not the whole story. TypeScript builds a control-flow graph and computes a narrowed type at each reachable program point. Guards, assignments, early returns, discriminant checks, and assertion functions all affect that flow type.

ts
type Response =
  | { ok: true; value: string }
  | { ok: false; error: Error };

function unwrap(response: Response): string {
  if (!response.ok) {
    throw response.error;
  }

  return response.value; // response is the successful variant here
}

function format(value: string | number | null): string {
  if (value === null) return "none";
  if (typeof value === "number") return value.toFixed(2);
  return value.toUpperCase();
}

This is refinement, not a permanent rewrite of the variable’s declared type. A later assignment can widen the current flow state again. Narrowing may also be invalidated around mutation or aliasing when the checker cannot prove a fact remains true.

Variance describes how assignability changes through a type constructor. A producer returning T is naturally covariant: a producer of Dog can be used where a producer of Animal is expected. A consumer accepting T is contravariant under strictFunctionTypes: a consumer able to handle every Animal can safely stand in for one asked to handle only Dog.

ts
type Producer<T> = () => T;
type Consumer<T> = (value: T) => void;

class Animal {
  move(): void {}
}

class Dog extends Animal {
  bark(): void {}
}

const dogProducer: Producer<Dog> = () => new Dog();
const animalProducer: Producer<Animal> = dogProducer;

const animalConsumer: Consumer<Animal> = (animal) => animal.move();
const dogConsumer: Consumer<Dog> = animalConsumer;

When a type parameter occurs in both input and output positions, it is commonly invariant in practice. Variance affects inference because input and output occurrences contribute bounds in different directions. TypeScript also has compatibility exceptions, notably method parameter bivariance, so variance should be treated as an assignability rule to inspect rather than a slogan to memorize.

Tip

Enable strict, especially noImplicitAny and strictFunctionTypes. Inference is most useful when the compiler reports where evidence is absent instead of silently replacing an unknown relationship with any.

Limits, annotations, and practical takeaways

Inference is intentionally local. TypeScript does not perform whole-program theorem proving, infer semantic units such as meters versus seconds, or inspect implementation intent that is absent from types. Dynamic property names, mutation through aliases, complex conditional types, overloaded APIs, and values entering through any can all erase useful evidence. Compiler performance matters too: a theoretically more precise answer may be rejected because finding it would make editor feedback unstable or expensive.

Annotations are therefore not a defeat. Use them at module boundaries, exported functions, recursive definitions, empty collections, callback declarations without context, and places where the intended abstraction is broader than the implementation. Let inference handle obvious locals, because repeating number beside 1 + 2 adds noise and can hide refactoring mistakes.

The practical model is compact:

  • Expressions provide local candidates; expected types provide context in the opposite direction.
  • Constraints relate candidates through assignability, while unification explains only the equality-shaped subset.
  • Widening balances literal precision against expected mutation; as const and satisfies let you choose deliberately.
  • Generic inference is strongest when a type parameter connects inputs to outputs and constraints express real relationships.
  • Control-flow analysis refines types at program points; it does not change the declared contract forever.
  • Variance determines the direction of safe substitution and therefore the bounds collected for generic parameters.
  • Explicit annotations belong where evidence is missing, public intent matters, or a stable boundary is more valuable than maximum local precision.

Once inference is seen as constrained information flow rather than magic, its failures become actionable. Ask where evidence should come from, which direction it travels, whether widening discarded precision, and whether mutation invalidated a fact. Those questions usually lead either to a small annotation or to a better API whose types make the intended relationship inferable.