Types & Inference
How TypeScript decides what type your value has — without you saying so.
Types & Inference
TypeScript is a structural type system bolted onto JavaScript. The single skill that separates a confident TS user from a frustrated one is predicting what TS will infer before you look. Once you can read code and say "that's string, that's \"hello\", that's (string | number)[]" without thinking, every other feature gets easier.
What inference is
When you write const x = "hello", you don't tell TypeScript a type. It picks one. The rules are mechanical, well-documented, and surprising in two specific places:
constof a primitive → the literal ("hello",42,true).letof a primitive → the widened primitive (string,number,boolean).- Array literals → the union of element types, widened.
- Object literals → property types are widened, even under
const.
That last one trips up almost everyone. We'll get there.
The four primitives
string, number, boolean, and the rarely-needed bigint and symbol. There's also null and undefined — under strictNullChecks (which you should always have on) they are their own types, not values of every type.
const a = "hello"; // type: "hello" ← literal
const b = 42; // type: 42
const c = true; // type: true
let a2 = "hello"; // type: string ← widened
let b2 = 42; // type: number
let c2 = true; // type: boolean
The reason: const says "this binding never reassigns". TypeScript can therefore commit to the literal — no future code can ever assign a different value to a. let says "this might change to anything of the same primitive shape", so TS widens to that primitive.
Why widening exists at all
Imagine TypeScript narrowed everything to the literal aggressively:
let count = 0;
count = 1; // error? "Type 1 is not assignable to type 0"?
That would be useless. So let widens. const doesn't need to widen, because reassignment is impossible.
The object-literal trap
This is the one that bites everyone:
const config = { kind: "circle" };
// ^? { kind: string } ← NOT { kind: "circle" }
Even though config is const, the binding is to the object, not to the property. The property can still be mutated:
config.kind = "square"; // legal — kind: string is mutable
So TypeScript widens kind to string. If you want the literal preserved, opt in with as const:
const config = { kind: "circle" } as const;
// ^? { readonly kind: "circle" }
as const is a contract you make with TS: "I promise this entire structure is immutable." TS rewards you with maximally narrow types.
Array inference
Arrays follow the union rule:
const xs = [1, "a"];
// ^? (string | number)[]
const ys = [1, "a"] as const;
// ^? readonly [1, "a"] ← tuple, fixed length, fixed element types
Without as const, you get a mutable array of the union. With it, you get a tuple type — a fundamentally different beast. Tuples have positional types and fixed length:
const ys = [1, "a"] as const;
ys[0]; // type: 1
ys[1]; // type: "a"
ys[2]; // ❌ Tuple type 'readonly [1, "a"]' has no element at index 2
Best common type and contextual typing
When TS can't pin down an exact type, it picks the best common type — the smallest type that fits all the values. For arrays, that's the union of element types. For function returns, it's the union of all return expressions:
function pick(b: boolean) {
if (b) return 1;
return "a";
}
// ^? string | number
The other half of inference is contextual typing. When TS knows the expected type from context, it pushes that down into expressions:
const handler: (x: number) => string = (x) => x.toFixed(2);
// ^? number ← contextual
x has no annotation, but TS knows the surrounding type expects (x: number) => string, so it gives x the contextual type number. This is why callback parameters in .map, .filter, event handlers, etc. don't need annotations — context provides them.
Widening at function returns
Function return types are inferred — and widened — exactly like let:
function makeKind() {
return "circle";
}
// ^? string ← widened, just like let
If you want the literal, annotate the return:
function makeKind(): "circle" {
return "circle";
}
Or use a const assertion at the return site:
function makeKind() {
return "circle" as const;
}
// ^? "circle"
When to annotate, when to infer
Rule of thumb: infer locally, annotate at boundaries.
- Inside a function, let TS infer. The annotations clutter without adding safety — you're going to read the code anyway.
- At public API surfaces (exported functions, library types, props), annotate. The annotation becomes the contract; infer would couple your callers to your implementation details.
A function whose return type is string because of an inferred string literal is a footgun: the next person who adds an if (n) return n; branch silently changes your public type to string | number. Annotating prevents that.
Quick reference
| You wrote | TS infers |
|---|---|
const x = "a" |
"a" |
let x = "a" |
string |
const x = 42 |
42 |
const o = { k: "a" } |
{ k: string } |
const o = { k: "a" } as const |
{ readonly k: "a" } |
const xs = [1, "a"] |
`(string |
const xs = [1, "a"] as const |
readonly [1, "a"] |
function f() { return "a" } |
() => string |
Memorise this table. Once it's in your fingers, every other TypeScript concept reads more cleanly.
Tools in the wild
3 tools- serviceTypeScript Playgroundfree tier
Hover any identifier to see its inferred type — the fastest feedback loop in the language.
- clitsc --noEmitfree tier
Type-check without emitting output. The CI workhorse.
- libraryts-resetfree tier
Sharper built-in inference: narrows .filter(Boolean), JSON.parse, fetch, etc.