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.
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.
1
2
3
4
5
6
7
8
9
let count =5
count =10console.log(count,typeof count)let id: string | number
id ="abc"console.log(id)
id =42console.log(id)
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
12
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
12
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.
1
2
3
4
5
6
7
8
9
functionadd(a: number, b: number): number {return a + b
}console.log(add(2,3))functiongreet(name: string){return`Hello, ${name}`}console.log(greet("Ada"))
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
123
functiondouble(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
123
functiondouble(x: number){return x *2}
Position
Needs annotation?
Why
Parameters
Almost always yes
No call-site value exists yet for inference to read from
Return type
Usually optional
TypeScript reads the return statements in the body
Variable with an initial value
Optional
Inferred from the value on the right of =
Variable declared before assignment
Yes
Nothing 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.
1
2
3
4
5
6
7
const nums =[1,2,3]const doubled = nums.map(x => x *2)console.log(doubled.join(", "))const words =["a","bb","ccc"]const lengths = words.map(w => w.length)console.log(lengths.join(", "))
You should see
2, 4, 6
1, 2, 3
Your turn
Add .filter(n => n > 2) before the .join on doubled — n is contextually typed as number too, with no annotation.
Error you will hit
TS2345: fighting contextual typing with your own annotation
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.
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.
1
2
3
4
5
6
7
8
9
10
let a ="hello"const b ="hello"const c ="hello"asconstconsole.log(a, b, c)const point ={ x:10, y:20}asconstconsole.log(point.x, point.y)const colors =["red","green","blue"]asconstconsole.log(colors.join(", "))
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
1let a ="hello"
2const b ="hello"
3const c ="hello"as const
4console.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
Step
Line
What happened
Variables now
1
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.
a (type) = string
2
2
b 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"
3
3
c 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"
4
4
At 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
12
const point ={ x:10, y:20}asconst
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
123
const point ={ x:10, y:20}asconstconst 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.
1
2
3
4
5
6
7
const mixed =[1,"two",3]console.log(mixed.join(", "))console.log(typeof mixed[0],typeof mixed[1])const nums =[1,2,3]const sum = nums.reduce((total, n)=> total + n,0)console.log(sum)
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 literal
Inferred type
Why
[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 context
An 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?
With no single type covering every element, the best common type algorithm falls back to a union of the distinct types it found — string and number — giving (string | number)[].
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.