Free Handbook · Runs in your browser

Type Annotations & Inference

How TypeScript decides a type for you, when to write one yourself, contextual typing in callbacks, as const narrowing, and how array literals get inferred.

0 / 105 lessons🔥 0 day streak
ShareXLinkedIn

Module 02 · what you'll be able to do

  • Read when TypeScript infers a type for you and when you need to write one yourself
  • Annotate function parameters and let return types infer where that is safe
  • Explain contextual typing and why a callback parameter often needs no annotation
  • Use as const to lock a value to its exact literal type instead of a widened one
  • Predict the type TypeScript infers for an array literal with mixed element types
01

Explicit annotations vs letting inference work

TypeScript can usually figure out a variable's type from the value you give it — this is type inference, and it runs constantly, whether or not you write an annotation. let count = 5 is inferred as number just as surely as if you had written let count: number = 5. The rule of thumb: let inference handle variables with an initial value, and write an explicit annotation only when there is nothing for the compiler to infer from — a variable declared before it is assigned, or one that needs to be wider than its first value suggests.

typescriptEdit it. ⌘/Ctrl + Enter runs.
You should see
10 number
abc
42
Your turn
Delete the : string | number annotation on id and see what single type gets inferred instead of the union.
Inference still widens
let count = 5 is inferred as the general type number, not the narrow literal type 5 — because let means the value can change, TypeScript widens to the type family it belongs to. const behaves differently, covered in the as const lesson.
Error you will hit

TS2322: reassigning outside the inferred type

typescript
let count = 5
count = "ten"
app.ts:2:1 - error TS2322: Type 'string' is not assignable to type 'number'.

2 count = "ten"
  ~~~~~
Why the compiler said that

Even with no explicit annotation, the first assignment fixed the inferred type of count as number for the rest of its scope. Inference is not a one-time guess — it behaves exactly like an annotation from that point on.

The fix

Assign a number, or add an explicit wider annotation like let count: number | string = 5 if it genuinely needs to hold both.

typescript
let count: number | string = 5
count = "ten"
Type inference
TypeScript deducing a type from context — an initial value, a return expression, a usage — without an explicit annotation.
Widening
Inference generalising a specific value to the type family it belongs to: 5 becomes number, not the literal type 5, when declared with let.
02

Annotating function parameters and return types

Function parameters almost always need an explicit annotation — TypeScript has no value to infer a parameter's type from until the function is actually called, so an unannotated parameter is treated as an implicit any and flagged under the compiler's noImplicitAny setting, which almost every project enables. Return types, on the other hand, are usually left to inference: TypeScript reads the function body and works out what it returns. Writing the return type explicitly on public, exported functions is still good practice — it documents the contract and catches a mistake inside the function instead of at every call site.

typescriptEdit it. ⌘/Ctrl + Enter runs.
You should see
5
Hello, Ada
Your turn
Delete the : number return annotation on add — the inferred return type does not change, because TypeScript reads it straight from the return a + b expression.
Error you will hit

TS7006: an unannotated parameter

typescript
function double(x) {
  return x * 2
}
app.ts:1:16 - error TS7006: Parameter 'x' implicitly has an 'any' type.

1 function double(x) {
                 ~
Why the compiler said that

With no value being passed at declaration time, there is nothing for inference to work from — TypeScript would have to guess. Under noImplicitAny it refuses to guess silently and asks for an annotation instead.

The fix

Add the parameter type.

typescript
function double(x: number) {
  return x * 2
}
PositionNeeds annotation?Why
ParametersAlmost always yesNo call-site value exists yet for inference to read from
Return typeUsually optionalTypeScript reads the return statements in the body
Variable with an initial valueOptionalInferred from the value on the right of =
Variable declared before assignmentYesNothing to infer from at the declaration line
03

Contextual typing: how .map(x => ...) just knows

Contextual typing is why nums.map(x => x * 2) never needs x: number written out. TypeScript already knows nums is number[], so it knows .map expects a callback whose parameter is a number — it works backwards from that expected function shape to infer the type of x inside the arrow function, without you annotating anything. This is the same mechanism behind untyped-looking callbacks for .filter, .forEach, .reduce, event handlers, and a Promise executor.

typescriptEdit it. ⌘/Ctrl + Enter runs.
You should see
2, 4, 6
1, 2, 3
Your turn
Add .filter(n => n > 2) before the .join on doubledn is contextually typed as number too, with no annotation.
Error you will hit

TS2345: fighting contextual typing with your own annotation

typescript
const nums: number[] = [1, 2, 3]
nums.forEach((x: string) => console.log(x))
app.ts:2:16 - error TS2345: Argument of type '(x: string) => void' is not assignable to parameter of type '(value: number, index: number, array: number[]) => void'.
  Types of parameters 'x' and 'value' are incompatible.
    Type 'number' is not assignable to type 'string'.

2 nums.forEach((x: string) => console.log(x))
                 ~~~~~~~~
Why the compiler said that

forEach is declared to call its callback with a number for each element of nums. Writing x: string overrides the type contextual typing would have inferred, and now the explicit annotation conflicts with what the array actually provides.

The fix

Remove the explicit annotation and let contextual typing infer x as number on its own.

typescript
const nums: number[] = [1, 2, 3]
nums.forEach(x => console.log(x))
  • Array methods: .map, .filter, .reduce, .forEach, .sort
  • Event handlers: button.addEventListener("click", e => ...)e is inferred as the right event type
  • A Promise executor: new Promise((resolve, reject) => ...)
  • Any place you pass a function into another function whose parameter type is already known
04

as const: narrowing to the exact literal

as const is a type assertion that tells TypeScript "infer the narrowest possible type for this, and make it read-only". A plain object literal infers widened property types ({ x: number }, not { x: 10 }); the same literal with as const infers the exact literal types for every property, and makes the object and any nested arrays readonly. It has zero effect at runtime — the values are identical — it only changes what the type checker will let you do afterward.

typescriptEdit it. ⌘/Ctrl + Enter runs.
You should see
hello hello hello
10 20
red, green, blue
Your turn
Remove as const from colors — the printed values stay identical; only the inferred type (string[] instead of a readonly tuple of exact literals) changes.
VisualizeWhy let, const and as const give three different types for the same valueStep 1 / 4
let a = "hello"
const b = "hello"
const c = "hello" as const
console.log(a, b, c)
Line 1

a is declared with let, so TypeScript widens the literal "hello" to the general type string — because a might later be reassigned to any other string.

Variables now
a (type)string
All 4 steps as a table
StepLineWhat happenedVariables now
11a is declared with let, so TypeScript widens the literal "hello" to the general type string — because a might later be reassigned to any other string.a (type) = string
22b is declared with const, so TypeScript keeps the exact literal type "hello" — a const binding can never be reassigned, so the narrower type is provably safe.b (type) = "hello"
33c uses as const explicitly. Here it does the same thing const already did for a plain string, but for objects and arrays as const is what triggers that narrowing — a plain const point = { x: 10 } alone still widens to { x: number }.c (type) = "hello"
44At runtime all three are just the string "hello" — the type differences only ever affect what the compiler allows you to do with them, never what actually prints.
Error you will hit

TS2540: writing to an as const property

typescript
const point = { x: 10, y: 20 } as const
point.x = 99
app.ts:2:1 - error TS2540: Cannot assign to 'x' because it is a read-only property.

2 point.x = 99
  ~~~~~~~
Why the compiler said that

as const marks every property of the object readonly, on top of narrowing its type to the exact literal 10. Reassigning it would break both promises at once.

The fix

Do not mutate an as const value — build a new object instead, e.g. { ...point, x: 99 }.

typescript
const point = { x: 10, y: 20 } as const
const moved = { ...point, x: 99 }
console.log(moved.x)
as const
A type assertion that narrows a value to its exact literal type and, for objects and arrays, marks every property/element readonly.
Readonly array/tuple
An array or tuple type whose elements cannot be reassigned by index and that has no mutating methods like push or pop in its type.
SeniorExplain how as const changes type inference, and give a real situation where you would reach for it.

as const narrows a value to its exact literal type instead of the widened general type inference would otherwise pick, and for objects and arrays it also makes them deeply readonly. A common real use: defining a fixed configuration or route table as an object literal with as const, so that Object.keys() or a mapped type over it can produce a precise union of the literal keys, instead of the useless general type string[].

What they are really testing: Whether the candidate can connect as const to a concrete payoff, not just recite the definition.

05

Best common type: inferring an array literal

When you write an array literal with elements of different types, TypeScript infers a type that covers every element — this is the "best common type" algorithm. For elements of unrelated types it falls back to a union of every distinct type it saw: [1, "two", 3] infers as (string | number)[]. When every element shares a common supertype, it uses that instead — an array of a base class and subclass instances infers as an array of the base class.

typescriptEdit it. ⌘/Ctrl + Enter runs.
You should see
1, two, 3
number string
6
Your turn
Add a true to the mixed array literal and predict how the inferred union type grows.
Array literalInferred typeWhy
[1, 2, 3]number[]Every element is the same type
[1, "two", 3](string | number)[]No single type covers every element, so TypeScript unions the distinct types it saw
[new Dog(), new Cat()]Animal[] (if both extend Animal)A shared supertype covers every element, so that is used instead of a union
[]Depends on contextAn empty literal with no annotation and no surrounding context often infers any[] — annotate it explicitly to avoid that
Quick check

What type does TypeScript infer for const arr = [1, "two", 3] with no annotation?

Mid-levelWhy does TypeScript sometimes infer a union type for an array literal instead of a compile error?

Array literals are still valid JavaScript with mixed content, so TypeScript does not reject them outright — instead its best common type algorithm looks for a type that every element is assignable to. If no single declared type covers everything, it falls back to a union of the distinct types it saw, which keeps the array usable while still tracking, precisely, what could be at each index.

What they are really testing: Whether the candidate understands inference is trying to describe what's there, not enforce uniformity by rejecting valid JavaScript.

JuniorIf a function parameter usually needs an annotation, why does an array-literal element or a variable with an initial value not?

Because inference needs something concrete to read a type from — a value, an expression, a return statement. A function parameter has no such value until the function is called, so there is nothing to infer from at the declaration. A variable with an initializer, or an element inside an array literal, already has a real value sitting right there, so TypeScript reads the type straight off it.

What they are really testing: Whether the candidate has the general principle — inference needs a value to look at — rather than memorised exceptions.

Finish the TypeScript handbook, then get hired

Sit the exam for your certificate, run your resume through the ATS checker, and see the jobs that ask for exactly this.

Check my resume
Found this course useful? Share it.
ShareXLinkedIn

Comments

0

Join the conversation. Sign in to leave a comment — we'd love to hear your thoughts.