Command Palette

Search for a command to run...

Hectal
PHASE 16Advanced ~25 min· topic 1 of 6

Topic 16.1

How the Checker Thinks

In one line

The checker answers one question over and over: can a value of type S go where type T is expected? Knowing the exact rules (assignability, freshness, comparability, widening, narrowing) lets you predict every error before you see it.

Think of it like this

A bouncer at a club has a checklist: ticket, ID, wristband. If your bag holds those three things and also a sandwich, you walk in; the bouncer only cares that everything on the list is there. But if you hand him a brand-new note you just wrote with an extra item on it that is not on his list, he stops you: a stray item on a note you just wrote is probably a typo. Assignability is the checklist. Freshness is the extra suspicion for things written on the spot.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

Assignable
A value of type S may be used where type T is expected. TypeScript decides this by comparing shapes.
Structural typing
Types are compared by what they contain, not by what they are called. Two differently named types with the same shape are interchangeable.
Fresh literal
An object literal written directly where it is used. It gets the extra check for unknown property names.
Excess property check
The error (TS2353) for a property in a fresh object literal that the target type does not list.
Comparable
A looser relation used by as and ===: the two types must overlap, not fit exactly.
Widening
Turning a literal type such as "hi" into its general type string when a value can change.
Narrowing
Making a type more specific at one point in the code, after a check or an assignment.
Declared type
The type you wrote (or that was inferred) for a variable. It does not change.
Covariant
Goes in the same direction: an array of a narrower type is accepted where an array of a wider type is expected.

Step by step

01Start with the question the checker asks

Almost every error you have ever seen is the same question with a different answer: is the type of this value assignable to the type of this slot? A slot is a typed variable, a parameter, a property, or a return.

The checker walks through the target's properties and checks each one against the source. Extra source properties are ignored. A missing or incompatible one is an error.

assignable.tswhole filets
type Point = { x: number; y: number };

const wide = { x: 1, y: 2, z: 3 };
const p: Point = wide; // fine: z is extra, nobody minds

02Freshness: the same data, a different verdict

Put the very same properties in a literal written right at the slot and the answer changes. The literal is fresh, so the excess property check runs and z is reported.

Run the first example below. Both fromVariable and the first show call are accepted; only the two fresh literals are refused.

Freshness: the same data, a different verdictdiagram
Rendering diagram…

03Comparability: how as is allowed to lie

Assignability asks whether S fits T. An assertion value as T asks a weaker question: do the two types overlap? If they do, the checker trusts you.

So text as "yes" passes (a string can be "yes"), text as number fails with TS2352, but { id: 1 } as { id: number; name: string } passes and produces an object that has no name.

04Widening: why a good-looking object is refused

const o = { kind: "circle", r: 2 } is stored with kind: string, because the property could be reassigned later. When you pass o where kind: "circle" | "square" is required, string is too wide and you get TS2322.

as const freezes the literal: every property becomes readonly with its exact value, and the object is accepted.

05Narrowing: the type depends on where you stand

The checker follows your code line by line. After id = "abc" the variable is a string; after id = 5 it is a number. Inside a separate function it falls back to the declared type, because that function may run at any time.

06never[]: the empty array that fits everywhere

{ items: [] } has items: never[], so items.push(1) is refused: nothing can be pushed into an array of never. The same never[] can be assigned to string[] and to number[], because never fits every type.

Try it yourself

  1. 1

    Predict the error, then check

    In the first example, change the call to show({ x: 1, y: 2 }). Predict whether the error disappears, then look at the Types panel.

    Now change it to show({ x: 1 }). The editor reports TS2345 with "Property 'y' is missing" underneath (TypeScript 7 words the same problem as TS2741). Why is it not TS2353?

  2. 2

    Spot the lie

    In the comparability example, change as { id: number; name: string } to as { name: string }. Predict: allowed or refused? Then read the error: it is TS2352, "neither type sufficiently overlaps", because { id: number } has no name and the target requires one.

  3. 3

    Freeze it

    In the widening example, delete as const from c. Read the new error on the fine line: it should be the same shape of error as the one on bad.

Code & diagrams

Assignable by shape, and the fresh-literal check

The variable wide is accepted (extra z is fine), but the same three properties written as a fresh literal are refused, both in the declaration and in the call. The code still runs: the checker only warns.

Console

(nothing was printed)

Expected console output

3
3
Comparability: what as and === allow

A string can be "yes", so the first assertion is fine. A string can never be a number, so both the second assertion and the comparison with 42 are refused. The last assertion overlaps, so the checker accepts it, and the program crashes at runtime.

Console

(nothing was printed)

Expected console output

true
false
Uncaught TypeError: Cannot read properties of undefined (reading 'length')
Widening: let, const, properties and as const

Only the const with a plain string keeps the literal type. The object's kind widened to string, which is why bad is refused while fine (built with as const) is accepted.

Console

(nothing was printed)

Expected console output

circle circle circle
Narrowing: declared type, narrowed type, and a callback that forgets

After each assignment the checker narrows id to the assigned kind. Inside later() the narrowing is gone and it sees the declared string | number. A let with no type and no value starts as any and takes the type of each assignment.

Console

(nothing was printed)

Expected console output

ABC
5.0
1
never[]: an empty array that fits every array slot

An empty literal in a property is never[]: nothing can be pushed. Assigned to string[] or number[] it is fine. In the generic call the empty array adds no candidate for T, so T is learned from the fallback alone.

Console

(nothing was printed)

Expected console output

0 0
none

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

Take the freeze off

In the widening example, delete as const from the definition of c.

terminal
$ Types panel
── what you'll see ──
index.ts(14,7): error TS2322: Type '{ kind: string; r: number; }' is not assignable to type 'Shape'.
Types of property 'kind' are incompatible.
Type 'string' is not assignable to type '"circle" | "square"'.
index.ts(15,7): error TS2322: Type '{ kind: string; r: number; }' is not assignable to type 'Shape'.
Types of property 'kind' are incompatible.
Type 'string' is not assignable to type '"circle" | "square"'.

Break #2

Cast through unknown

In the comparability example, replace const b = text as number; with const b = text as unknown as number; and add console.log(b.toFixed(1)); after it.

terminal
$ Console
── what you'll see ──
Uncaught TypeError: b.toFixed is not a function

Myth vs fact

Myth

Extra properties are always an error in TypeScript.

Fact

Only in a fresh object literal written right at the slot. The same object stored in a variable first is accepted, because assignability ignores extra properties.

Myth

as converts a value to another type.

Fact

as converts nothing. It only tells the checker to believe you, and it is allowed whenever the two types overlap. The runtime value is untouched.

Myth

A const object is fully frozen, so its literals keep their exact types.

Fact

const stops reassigning the variable, not the properties. { kind: "circle" } still gets kind: string. Use as const to freeze the properties' types.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    Excess property checking applies to a fresh literal wherever it is written at a slot: directly, inside an array literal (const arr: Point[] = [{ x: 1, y: 2, z: 3 }] reports z), or in a branch of a conditional expression. It does not apply to properties that arrive through a spread: const q: Point = { ...wide } is accepted even though wide has z, while a property written by hand next to the spread ({ ...wide, w: 4 }) is still checked. Try all of these in the first example.

  • ▸

    In practice an assertion s as T is accepted when T is comparable to S or S is comparable to T, so it works in both directions: a downcast to a more specific type ({ id: number } to { id: number; name: string }) compiles, and so does an upcast. Only types with no overlap at all are refused.

  • ▸

    Comparability being weaker than assignability is the reason value as T can compile when value is not assignable to T. The safe direction (upcast) never needs as. Most lint rules about assertions (Topic 16.5) target exactly the downcasts and double casts.

Remember this

  1. 1

    The problem this solves: Programs hand values from one place to another thousands of times, and the checker needs one clear rule for whether each handover is safe. That rule is called assignability, and nearly every error message you will ever read comes from it. (Recall from Topic 3.5: types are compared by shape, not by name.) Assignable means: a value of type S may be put where type T is expected (a variable, a parameter, a return). TypeScript decides this by shape, which is called structural typing: S is assignable to T when S has every property T asks for, and each of those properties is itself assignable. Extra properties on S are fine. Names of types do not matter, only their shapes. That is why { x: 1, y: 2, z: 3 } stored in a variable can be passed where Point (just x and y) is expected.

  2. 2

    Freshness is the one exception to "extra properties are fine". An object literal written directly in the place where it is used (a fresh literal) gets the excess property check: the checker compares each property name with the target and reports one the target does not list (error TS2353). The reason is practical: nobody writes a literal and wants to throw half of it away, so an unknown name is almost always a typo. The moment the object is stored in a variable, it stops being fresh, and the extra properties are allowed again. Freshness is a rule about where the literal was written, not about the object itself.

  3. 3

    Comparability is a second, looser relation. The checker uses it for type assertions (value as T) and for comparisons (a === b). Roughly: an assertion or comparison is allowed when the two types overlap, meaning a value could plausibly be both. text as "yes" is fine because "yes" is a kind of string; text as number is refused (TS2352) because no value is both. text === 42 on a string is refused too (TS2367). Comparability is why as can be used to lie: { id: 1 } as { id: number; name: string } overlaps, so the checker allows it, and name is missing at runtime.

  4. 4

    Widening is what TypeScript does to literal types when a value can change (Recall from Topic 4.2: a literal type is one exact value such as "hi", and as const keeps it). A const can never change, so const b = "hi" keeps the exact type "hi". A let can change, so let a = "hi" becomes plain string. Properties of an object literal are mutable, so { kind: "circle" } gets kind: string even in a const. This is the usual reason a value that looks right is refused where a union of literals is expected. The three fixes are an annotation, as const, or writing the object where it is used so the target type guides it (Topic 16.2).

  5. 5

    Narrowing is the opposite move (Recall from Topic 4.3: a check such as typeof x === "string" makes the checker treat x as a string inside the if): at each point in the code the checker keeps the declared type (what you wrote) and the narrowed type (what it knows right now, after a check or an assignment). let id: string | number = "abc" is declared string | number, but right after the assignment it is narrowed to string, so id.toUpperCase() is allowed. Narrowing is lost inside a function that runs later, because the checker cannot know when it will be called or what id will hold by then.

  6. 6

    The odd-looking [] as never[] follows from all of this. (Recall from Topic 5.5: never is the type with no values.) An empty array literal has no elements to learn a type from, so in a property position it becomes never[] ("an array that can hold nothing"). never is assignable to every type, and arrays are covariant (an array of a narrower type fits where an array of a wider type is expected), so a never[] can be assigned to a string[] or a number[]. Writing [] as never[] on purpose gives you one shared empty value that fits every array slot, which is handy for defaults.

Explain it without notes

01

How does TypeScript decide whether one type is assignable to another?

02

Why is const p: Point = wide accepted when const p: Point = { x: 1, y: 2, z: 3 } is an error?

03

What is the difference between assignability and comparability?

04

Why does const o = { kind: "circle" } fail to pass as { kind: "circle" | "square" }, and what are the fixes?

05

What does [] as never[] do, and why does it fit every array type?

Practice

01

This call is refused. Give two different fixes that do not use as. ``ts type Mode = "light" | "dark"; const settings = { mode: "dark", size: 12 }; function apply(s: { mode: Mode; size: number }) { console.log(s.mode, s.size); } apply(settings);

02

Without running it, say which lines are errors and why. ``ts const a: { n: number } = { n: 1, extra: true }; const o = { n: 1, extra: true }; const b: { n: number } = o; const c = { n: 1, extra: true } as { n: number };

Trade-offs

  • ↔

    Structural typing makes code flexible (any object with the right shape works, no inheritance needed) but means a value can satisfy a type you never intended. Branded types (Topic 10.4) add back a name where it matters.

  • ↔

    Freshness catches typos in literals but not in variables. If you build the object first and pass it later, you lose the check, so annotate the variable with its target type when you want it back.

Done when you can

  • I can explain assignability as a question about shapes, and say why extra properties are allowed.

  • I can say what a fresh literal is and predict when TS2353 appears.

  • I can explain why as can compile a lie and why as unknown as T always compiles.

  • I can predict the widened type of a let, a const, and an object property.

  • I can tell the declared type from the narrowed type, and say why narrowing is lost in a callback.

  • I can explain why [] as never[] fits every array type.