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
asand===: 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.
type Point = { x: number; y: number };
const wide = { x: 1, y: 2, z: 3 };
const p: Point = wide; // fine: z is extra, nobody minds02Freshness: 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.
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
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 reportsTS2345with "Property 'y' is missing" underneath (TypeScript 7 words the same problem asTS2741). Why is it notTS2353? - 2
Spot the lie
In the comparability example, change
as { id: number; name: string }toas { name: string }. Predict: allowed or refused? Then read the error: it isTS2352, "neither type sufficiently overlaps", because{ id: number }has nonameand the target requires one. - 3
Freeze it
In the widening example, delete
as constfromc. Read the new error on thefineline: it should be the same shape of error as the one onbad.
Code & diagrams
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
3A 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')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 circleAfter 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
1An 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
noneBreak 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.
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.
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 }]reportsz), 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 thoughwidehasz, 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 Tis accepted whenTis comparable toSorSis comparable toT, 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 Tcan compile whenvalueis not assignable toT. The safe direction (upcast) never needsas. Most lint rules about assertions (Topic 16.5) target exactly the downcasts and double casts.
Remember this
- 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
Smay be put where typeTis expected (a variable, a parameter, a return). TypeScript decides this by shape, which is called structural typing:Sis assignable toTwhenShas every propertyTasks for, and each of those properties is itself assignable. Extra properties onSare 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 wherePoint(justxandy) is expected. - 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
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 ofstring;text as numberis refused (TS2352) because no value is both.text === 42on astringis refused too (TS2367). Comparability is whyascan be used to lie:{ id: 1 } as { id: number; name: string }overlaps, so the checker allows it, andnameis missing at runtime. - 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", andas constkeeps it). Aconstcan never change, soconst b = "hi"keeps the exact type"hi". Aletcan change, solet a = "hi"becomes plainstring. Properties of an object literal are mutable, so{ kind: "circle" }getskind: stringeven in aconst. 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
Narrowing is the opposite move (Recall from Topic 4.3: a check such as
typeof x === "string"makes the checker treatxas astringinside theif): 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 declaredstring | number, but right after the assignment it is narrowed tostring, soid.toUpperCase()is allowed. Narrowing is lost inside a function that runs later, because the checker cannot know when it will be called or whatidwill hold by then. - 6
The odd-looking
[] as never[]follows from all of this. (Recall from Topic 5.5:neveris the type with no values.) An empty array literal has no elements to learn a type from, so in a property position it becomesnever[]("an array that can hold nothing").neveris 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 anever[]can be assigned to astring[]or anumber[]. 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
How does TypeScript decide whether one type is assignable to another?
Why is const p: Point = wide accepted when const p: Point = { x: 1, y: 2, z: 3 } is an error?
What is the difference between assignability and comparability?
Why does const o = { kind: "circle" } fail to pass as { kind: "circle" | "square" }, and what are the fixes?
What does [] as never[] do, and why does it fit every array type?
Practice
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);
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
ascan compile a lie and whyas unknown as Talways compiles.I can predict the widened type of a
let, aconst, 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.