#Type annotations vs type inference
An annotation is you telling the compiler a type: let count: number = 0. Inference is the compiler working the type out from the value: let count = 0 gets number all by itself. Both produce exactly the same type information; the difference is only who wrote it down.
TypeScript infers far more than people expect. Every variable with an initialiser, every function's return type, every array and object literal gets a type without you saying anything. So the real skill isn't "how do I annotate"; it's knowing where inference can't reach or gives an answer you don't want, and annotating exactly there.
A useful mental model: inference flows from values to names. When there's a value to look at (an initialiser, a return expression), the compiler reads its type and gives it to the name. When there's no value yet (a function parameter, a variable declared empty), there's nothing to read, and you have to supply the type.
function add(a: number, b: number) {
return a + b;
}
const total = add(2, 3); // const total: number- The parameters
aandbare annotated because there is nothing for the compiler to infer them from. TypeScript never guesses a parameter's type from how the body uses it. Without the annotations, strict mode reports TS7006: "Parameter 'a' implicitly has an 'any' type." - There's no return annotation, so the checker looks at every
returnexpression in the body. There is one,a + b, and with twonumberoperands+producesnumber. The inferred signature isfunction add(a: number, b: number): number. That's the "automatically inferring return type to number since inputs are both number" from my notes. - At the call
add(2, 3)the checker matches2and3againstnumberand accepts them. The call expression has the function's return type,number. totalhas no annotation, so it takes the type of its initialiser:number. (Not the literal5: the compiler computes types, not values. It never runs2 + 3.)- If you later changed
btostring, the return type would silently becomestringand every caller would get the new type without any error atadditself. That's the argument for annotating return types on functions other people call (more below).
let vs constVariables get their type from their initialiser, and the details depend on let vs const and on what's in the literal:
let count = 0; // let count: number
const limit = 10; // const limit: 10
const tags = ["a", "b"]; // const tags: string[]
const mixed = [1, "two", null]; // const mixed: (string | number | null)[]
let status: "idle" | "loading" = "idle";
status = "loading";
status = "done"; // ❌ Type '"done"' is not assignable to type '"idle" | "loading"'.let count = 0: the value0has the literal type0, but because aletcan be reassigned, the compiler widens it tonumber. Alettyped as just0would be useless.const limit = 10: aconstcan never change, so the narrow literal type10is kept. (Widening is covered properly in Primitive and literal types.)tags: an array literal's element type is the best common type of its elements, widened. Both are strings, sostring[]. Arrays are mutable, so element literals are widened even underconst: theconstonly stops you reassigningtags, not pushing into it.mixed: no single element type covers the others, so the element type becomes the union of all of them:(string | number | null)[].statusis annotated because inference would givestring(it's aletinitialised with a string). The annotation narrows the variable to the two states you actually allow, so assigning"done"fails with TS2322: Type '"done"' is not assignable to type '"idle" | "loading"'. Here the annotation isn't noise; it's the specification.
The most valuable annotation in day-to-day code is an explicit return type, because it moves errors to where the bug actually is:
type User = { id: number; name: string };
function findUser(users: User[], id: number): User {
const user = users.find((u) => u.id === id);
return user; // ❌ Type 'User | undefined' is not assignable to type 'User'.
}users.find(...)is declared in the standard library to returnUser | undefined, because there may be no match. Souseris inferred asUser | undefined.- The function promises
: User. The checker compares the type of thereturnexpression against that promise and reports TS2322: Type 'User | undefined' is not assignable to type 'User', with the detail "Type 'undefined' is not assignable to type 'User'." - Without the
: Userannotation there'd be no error here at all. The return type would be inferred asUser | undefined, and the errors would appear later at each call site that doesfindUser(...).name, far from the cause. - The annotation also documents intent: a reader sees the contract without reading the body. The fix is to decide what "not found" means: return
User | undefinedhonestly, or throw.
Rules of thumb:
- Let inference do local variables.
const name: string = "Ada"adds nothing and is one more thing to keep in sync. - Always annotate parameters. The compiler can't infer them, except through contextual typing.
- Annotate return types of exported or public functions. They're a contract, and a contract should be written down. For small private helpers, inference is fine.
- Annotate when there's no initialiser or it's empty:
let timer: NodeJS.Timeout | undefined;,const ids: number[] = [];. - Annotate to narrow on purpose, like
statusabove, or use as const / satisfies when you want to keep the literal types inference would otherwise widen.