Type Inference
TypeScript rarely needs you to spell out every type, because the compiler infers them from the information already in your code. When a variable has an initializer, a function has a return statement, or a callback runs in a known context, TypeScript figures out the type for you. Good TypeScript leans heavily on this inference and reserves explicit annotations for the places the compiler genuinely cannot see. This page covers how inference works, where it shines, and the few situations where you must step in.
Inference from initializers
The most common form of inference happens when you declare a variable with a value. TypeScript reads the initializer and assigns the variable that type permanently — no annotation required.
let count = 42; // inferred: number
let title = "Docs"; // inferred: string
let active = true; // inferred: boolean
// ❌ Error: Type 'string' is not assignable to type 'number'.
count = "many";
Even though you never wrote : number, count is locked to number and rejects a later string assignment. The inferred type is exactly as strict as an explicit annotation would have been — you simply did not have to type it.
const vs let literal widening
The kind of type TypeScript infers depends on how the binding can change. A let binding is reassignable, so TypeScript widens the value to its general primitive type. A const can never change, so TypeScript infers the narrow literal type instead.
let mutable = "ready"; // inferred: string (widened)
const frozen = "ready"; // inferred: "ready" (literal type)
let n = 10; // inferred: number
const m = 10; // inferred: 10
// `frozen` is a literal type, usable where "ready" is required
type State = "ready" | "busy";
const s: State = frozen; // ✅
This distinction matters when feeding values into union types: a const literal slots directly into a literal union, while a widened let value does not. For object properties you can opt into literal types explicitly with const assertions.
Best common type
When TypeScript must infer a single type from several values — most often the elements of an array — it computes the best common type that covers them all.
const mixed = [1, 2, 3]; // inferred: number[]
const items = [1, "two", 3]; // inferred: (string | number)[]
const nodes = [new Date(), null]; // inferred: (Date | null)[]
TypeScript looks at every element and produces the narrowest type they all satisfy, forming a union when they differ. If no single type fits cleanly, it falls back to a union rather than guessing one element as canonical.
Contextual typing
Inference also flows in the opposite direction. When the surrounding context already implies a type — a callback passed to a known method, an event handler, a typed variable — TypeScript supplies the parameter types for you. This is called contextual typing.
const numbers = [1, 2, 3];
// `n` is contextually typed as number — no annotation needed
numbers.forEach((n) => console.log(n.toFixed(2)));
// The event is typed from the DOM lib
window.addEventListener("click", (event) => {
console.log(event.button); // event: MouseEvent
});
Because forEach and addEventListener declare what they call their callbacks with, annotating n or event would be redundant noise. Contextual typing is why idiomatic TypeScript callbacks usually carry no parameter annotations at all.
Return type inference
You generally do not annotate function return types either. TypeScript infers them from the return statements, keeping signatures concise while staying fully type-safe.
function add(a: number, b: number) {
return a + b; // inferred return: number
}
function makeUser(name: string) {
return { name, createdAt: new Date() };
// inferred: { name: string; createdAt: Date }
}
The inferred return type updates automatically as the body changes. Note that the parameters (a, b, name) still need annotations — there is no context to infer them from, which is the subject of the next section.
When inference is enough vs when to annotate
Inference covers most local code, but it cannot read your intent in a few spots. The table summarizes where to rely on it and where an explicit annotation earns its keep.
| Situation | Inference works? | Recommendation |
|---|---|---|
const/let with an initializer | Yes | Let it infer |
| Array and object literals | Yes | Let it infer |
| Callback parameters in a known context | Yes | Let it infer |
| Function parameters | No | Always annotate |
| Variable declared without a value | No | Annotate the binding |
| Public API / exported function returns | Partly | Annotate for a stable contract |
| Widening you do not want | N/A | Annotate or use as const |
For parameters and uninitialized variables there simply is no source to infer from, which is exactly what noImplicitAny guards against.
noImplicitAny
When TypeScript cannot infer a type and you did not annotate one, it would otherwise fall back to any — silently disabling type checking. The noImplicitAny compiler option (included in strict) turns that silent fallback into an error.
// tsconfig.json
{
"compilerOptions": {
"noImplicitAny": true // on by default under "strict": true
}
}
// ❌ Error: Parameter 'name' implicitly has an 'any' type.
function greet(name) {
return `Hi, ${name}`;
}
// ✅ Fixed with an annotation
function greetTyped(name: string) {
return `Hi, ${name}`;
}
With noImplicitAny on, the compiler forces you to annotate exactly the cases inference cannot handle — turning gaps in inference into actionable errors rather than hidden any values.
Resist the urge to annotate everything. Redundant annotations like
const count: number = 42add noise and can drift out of sync with the value. Annotate boundaries and gaps; let inference handle the rest.
Best Practices
- Let inference type local variables and array/object literals — avoid redundant annotations on initialized bindings.
- Always annotate function parameters; there is no context for the compiler to infer them.
- Use
constto get literal types when you need a value to fit a literal union. - Skip return-type annotations on internal helpers, but add them on exported public APIs for a stable contract.
- Keep
noImplicitAny(andstrict) on so missing types surface as errors, not silentany. - Reach for
as constwhen you want literal types preserved instead of widened. - Trust contextual typing for callbacks rather than re-annotating parameters the platform already types.