typescript · level 1

Types & Inference

How TypeScript decides what type your value has — without you saying so.

150 XP

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:

  • const of a primitive → the literal ("hello", 42, true).
  • let of 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
  • Hover any identifier to see its inferred type — the fastest feedback loop in the language.

    service
  • tsc --noEmitfree tier

    Type-check without emitting output. The CI workhorse.

    cli
  • ts-resetfree tier

    Sharper built-in inference: narrows .filter(Boolean), JSON.parse, fetch, etc.

    library