You write TypeScript every day, so you know the offer: annotate your JavaScript, and a checker catches the mistakes before they run. This guide is about what that checker really is, because it is stranger and more capable than "JavaScript with types bolted on." TypeScript is two languages sharing one syntax — a value language, which is JavaScript and runs, and a type language, which describes it and is then thrown away. Almost everything advanced in the type system falls out of two facts: the type language is a real, mostly-pure functional language, with its own variables, functions, and control flow; and it is removed completely before your program runs, which leaves compatibility to be decided by shape rather than by name. It all runs on TypeScript 7.0.2, the compiler rewritten in Go, under Node 22.22.2, and every error and every line of emitted JavaScript here is real output. The title is the claim, so the guide starts where it has to: by watching the second language disappear.
The two languages, and the one that gets erased
Here is a file that uses most of what people mean by "TypeScript": a class, an interface, a generic function, and two kinds of enum.
class Point {
constructor(public x: number, public y: number) {}
dist(): number { return Math.hypot(this.x, this.y); }
}
interface Shape { area(): number; } // pure type
function identity<T>(x: T): T { return x; } // generic
enum Color { Red, Green, Blue }
const enum Direction { Up, Down } // const enum
const p = new Point(3, 4);
const n = identity<number>(5); // explicit type arg
const s: Shape = { area: () => 1 }; // annotation, no runtime trace
console.log(p.dist(), n, Color.Green, Direction.Up, s.area());
Compile it with tsc --target es2022 --module commonjs, and read the JavaScript that comes out.
"use strict";
class Point {
x;
y;
constructor(x, y) {
this.x = x;
this.y = y;
}
dist() { return Math.hypot(this.x, this.y); }
}
function identity(x) { return x; } // generic
var Color;
(function (Color) {
Color[Color["Red"] = 0] = "Red";
Color[Color["Green"] = 1] = "Green";
Color[Color["Blue"] = 2] = "Blue";
})(Color || (Color = {}));
const p = new Point(3, 4);
const n = identity(5); // explicit type arg
const s = { area: () => 1 }; // annotation, no runtime trace
console.log(p.dist(), n, Color.Green, 0 /* Direction.Up */, s.area());
The interface is gone. Not narrowed, not turned into a runtime assertion: there is no trace of Shape anywhere in the output, and the annotation const s: Shape lost its : Shape along with it. Only the value { area: () => 1 } survived.
The generic lost its type parameter. identity<T> became function identity(x), and the call identity<number>(5) emitted as identity(5). Even the type argument you wrote out by hand is gone.
The plain enum is the exception, and it earns a look. Color emitted a real runtime object — that self-invoking function builds a two-way map, so at runtime both Color["Red"] === 0 and Color[0] === "Red" hold. An enum is one of the few type-language constructs that leaves a value behind. The const enum beside it did not: Direction emitted nothing, and its one use Direction.Up was pasted straight in as the literal 0. It was a lookup table the compiler read while compiling and then discarded.
This deletion has a name. Erasure is the removal of the whole type language before the code runs; what executes is the value language that was sitting underneath. The JavaScript above is not an approximation of your program — it is your program. It is also, exactly, the language the sibling JavaScript guide takes apart: the file the compiler emitted is that language and nothing more.
Erasure is easier to reason about once you notice that every name you write lives in one of two worlds, and a few live in both.
VALUE SPACE TYPE SPACE
(exists when the code runs) (exists for the checker; erased)
───────────────────────── ─────────────────────────────
const · let · function type Radius = …
new Point() interface Shape
the Color enum object Point (as a shape) · keyof T · T[K]
│ │
└────── class Point ─────────────┤ one name,
└────── namespace G ─────────────┘ both spaces
A const, a let, a function — those live in value space: they are there when the program runs. An interface or a type alias lives in type space: it exists for the checker and is erased. The two are separate namespaces, so a value and a type can share a name and never collide.
A few declarations put one name into both spaces at once, and these are the ones worth holding. A class is the clearest case. class Point defines a value (the constructor you call with new, which you can see survived in the output) and a type, the shape of an instance, under a single name. In const p: Point = new Point(3, 4) the first Point is the type and the second is the value, and the compiler reads each in its own space. A namespace does the same, handing out both values and types from one name; chapter 14 returns to why that is useful.
Because the type language is gone by the time the code runs, four facts follow that look unrelated until you see they are one fact. The first you can trigger right now. You just watched Shape disappear, so ask the running program whether a value is a Shape:
$ tsc --ignoreConfig --strict --noEmit bridges.ts
bridges.ts(11,20): error TS2693: 'Shape' only refers to a type, but is being used as a value here.
Shape is a type and nothing else. There is no value named Shape for instanceof to test against, so the check will not even compile. That is the first consequence, and here are all four, each the subject of a later chapter:
- There is no runtime type information. You cannot ask what type a value "really" is, because no value carries the answer (chapter 6).
- Generics cannot be inspected while running. The
<T>is gone, so nothing can read it back (chapter 7). asis not a cast. It changes what the checker believes and does nothing to the value — a string asserted tonumberis still a string when it runs (chapters 6 and 15).- A value arriving from outside —
JSON.parse, afetchresponse,process.env— has the type you claimed for it, not one anything verified (chapter 15).
This is where the two sibling languages split hardest. Python annotates too, and makes the opposite decision: it keeps the annotations, as __annotations__ data hanging off the function object, for a separate checker to read later. TypeScript keeps nothing. The checker runs once, at your desk, and the runtime never hears that it happened — which is the whole reason the last third of this guide exists.
typeof, keyof, and T[K] are the wormholes between them
You have a configuration object sitting in your code as a value, and you want a type that describes it. The tempting move is to hand-write an interface beside it and keep the two in sync by discipline — every time the object gains a field, remember to add it to the interface. Discipline is the thing computers are for not needing. Read the value instead:
const config = { host: "localhost", port: 8080, tls: true };
type Config = typeof config; // value -> type
type ConfigKey = keyof Config; // "host" | "port" | "tls"
type Port = Config["port"]; // indexed access -> number
typeof config reads a value into a type. Config is now { host: string; port: number; tls: boolean }, computed from the object, and it moves whenever the object moves — add a field and the type gains it for free. This is the first bridge, and it runs one way only: value space into type space. There is no bridge back, because the type is erased and there would be nothing at runtime to read.
That keyword is the same word JavaScript already gave you, doing an unrelated job on the other side of the erasure line. At runtime, typeof x is an expression that returns a string like "object". In a type position, typeof x means "the type the checker inferred for x." One spelling, two languages: the value-language typeof produces a value, the type-language typeof produces a type.
The second bridge, keyof, takes a type and gives back the union of its keys as string-literal types. keyof Config is "host" | "port" | "tls". Feed it a key that does not exist and the checker tells you the exact set it will accept:
$ tsc --ignoreConfig --strict --noEmit bridges2.ts
bridges2.ts(4,7): error TS2322: Type '"user"' is not assignable to type '"host" | "port" | "tls"'.
The third bridge is indexed access, T[K]: reach into a type by key and pull out the type sitting there. Config["port"] is number. The brackets are the same ones you use to index a value, but here both the thing indexed and the index are types, so the result is a type:
bridges2.ts(6,7): error TS2322: Type 'string' is not assignable to type 'number'.
The two navigating bridges compose into the idiom the whole back half of the language leans on. Index a type not by one key but by all of them at once — T[keyof T] — and you get the union of every member type:
bridges2.ts(8,7): error TS2322: Type 'string | number | boolean' is not assignable to type 'null'.
Config[keyof Config] is string | number | boolean, the types of every value in the object gathered into one union. (That last error is the reveal trick this guide uses throughout: assign a value to null, and the checker names the value's real type in the complaint. Here it named the union.)
These three are the only doorways between the two worlds. typeof pulls a value's shape into type space; keyof and T[K] walk around inside it. When chapter 12 maps over keyof T, or chapter 13 shows Pick reaching in with T[P], no new machinery arrives — it is these bridges, used in combination.
Types compare by shape, not by name
Here is a class that never mentions the interface it is about to satisfy. Predict whether it type-checks.
interface Named { name: string; }
class Person { constructor(public name: string) {} }
const p1: Named = new Person("Ada"); // no 'implements Named' anywhere
It checks. Person is assignable to Named with no declared relationship between them, because TypeScript decides assignability by asking one question: does the value have at least the shape the target requires? Define the term first, since the whole chapter turns on it. Type A is assignable to type B when a value of A can stand in wherever a B is expected. A nominal language — Java, C# — answers that by looking at declarations: A is a B only if A was declared to extend or implement B. TypeScript never looks at names or ancestry. It compares shapes. This is structural typing, and it is the default for everything.
So two types with different names and identical shapes are the same type as far as assignability is concerned:
interface Point2D { x: number; y: number; }
interface Vector2D { x: number; y: number; }
let pt: Point2D = { x: 1, y: 2 };
let vec: Vector2D = pt; // fine: the checker is name-blind
Point2D and Vector2D were declared separately, share not one line of code, and are freely interchangeable. The names are for you; the checker sees { x: number; y: number } on both sides and is satisfied.
If only shape matters, what stops you passing an object that has more than required? Nothing — and that is correct. A value with the required fields plus extras still satisfies the requirement, the same way a person with a name and an age is still, among other things, a named thing. Assigning a richer object to a narrower type is ordinary and safe.
Except once. There is a single place where TypeScript abandons pure structural typing and complains about an extra property, and it exists to catch one specific mistake — a typo in an object you are writing out by hand:
interface Opts { width: number; }
const bad: Opts = { width: 10, height: 20 }; // where does height go?
$ tsc --ignoreConfig --strict --noEmit structural.ts
structural.ts(10,32): error TS2353: Object literal may only specify known properties, and 'height' does not exist in type 'Opts'.
This is the excess-property check. When you assign an object literal straight into a typed slot, the checker treats the extra height not as a harmless surplus but as a likely error — you meant width, or you are passing an option the function will silently ignore. The same check fires when the literal is an argument at a call site, which is where it earns its keep:
structural.ts(12,18): error TS2353: Object literal may only specify known properties, and 'height' does not exist in type 'Opts'.
The mechanism is worth naming exactly, because it looks like an inconsistency until you see it. The check applies only to a fresh object literal — one written directly at the point of assignment or call. Route the identical object through a variable first, and the freshness is spent:
const raw = { width: 10, height: 20 };
const okThroughVar: Opts = raw; // no error
raw is no longer a fresh literal; it is a value with an inferred type, and the ordinary structural rule takes over — extra fields are fine. So the excess-property check is not a second, stricter mode of assignability. It is a one-time typo-catcher that runs at the moment you write a literal down, and nowhere else.
Python, the sibling language, starts from the opposite default and is worth the contrast. There a class is its declared type and its base classes, nominally; structural matching is opt-in, through a Protocol you inherit from. Even then the runtime check is shallow — isinstance against a @runtime_checkable protocol tests only that the attribute names exist, so a class whose close is the integer 42 passes as "closeable." TypeScript makes structure the ground for everything and offers no runtime structural check at all, because — from chapter 1 — there is no runtime type information for one to consult. The shape is checked once, at your desk, and then it is gone.
const doesn't freeze what you think
Declare an object with const and ask the checker what type it inferred.
const shape = { kind: "circle" };
You would be forgiven for expecting { kind: "circle" }. The reveal trick — assign it to null and read the complaint — says otherwise:
$ tsc --ignoreConfig --strict --noEmit widening.ts
widening.ts(7,43): error TS2322: Type '{ kind: string; }' is not assignable to type 'null'.
{ kind: string }. The "circle" is gone, replaced by the general type string, even though you wrote const and never touched the object again. To see why, start with the case that behaves the way you expected.
A string literal like "hello" has, for a moment, a literal type — the type "hello", whose only value is that exact string. The checker rarely keeps it. It widens the literal to the general type, string, on the assumption that a variable holding "hello" today will hold some other string tomorrow. Whether it widens depends on the binding:
| Declaration | Inferred type |
|---|---|
let a = "hello" |
string |
const b = "hello" |
"hello" |
let c = 42 |
number |
const d = 42 |
42 |
let widens, because you can reassign it. const on a primitive does not, because the binding cannot change — the literal type is safe to keep, and useful to keep. That is the model people carry away: const keeps the literal.
The model breaks on objects because const guarantees less than it looks like it guarantees. const shape = … fixes the binding — you cannot point shape at a different object. It says nothing about the object's contents. shape.kind = "square" is perfectly legal. So each property is, as far as reassignment goes, a let, and its literal widens exactly as a let would. const never reached the properties; it only ever held the variable.
The tool that does reach them is as const:
widening.ts(6,43): error TS2322: Type 'readonly [1, 2, 3]' is not assignable to type 'null'.
widening.ts(8,49): error TS2322: Type '{ readonly kind: "circle"; }' is not assignable to type 'null'.
{ kind: "circle" } as const infers { readonly kind: "circle" }, and [1, 2, 3] as const infers readonly [1, 2, 3] — a tuple, not number[]. as const says: keep every literal at its narrowest type, and make it readonly throughout, so nothing can later widen it by writing to it.
This is not a stylistic nicety. Chapter 6 narrows a value by its kind field against a set of literal types — "circle" | "square" | "tri" — and that only works if kind is one of those literals. Let it widen to string and the narrowing has nothing to bite on. Whether your constant data carries "circle" or merely string is decided right here, by whether you reached for as const.
An enum is TypeScript's other answer to "a fixed set of named constants," and it is the sharp edge of this chapter, because unlike everything else in the type language it is not free. It emits a real object:
var Color;
(function (Color) {
Color[Color["Red"] = 0] = "Red";
Color[Color["Green"] = 1] = "Green";
Color[Color["Blue"] = 2] = "Blue";
})(Color || (Color = {}));
At runtime Color[1] is "Green" — a numeric enum builds a reverse map you are now paying for on every load. const enum skips the object by inlining each use into its literal — you watched Direction.Up become 0 in chapter 1. But an inlined value the compiler substituted at build time turns fragile once the const enum is shared across a project boundary, in ways the TypeScript handbook collects under const enum pitfalls. The alternative that keeps the literal types and drops the machinery is a plain object frozen with as const:
const Direction = { Up: 0, Down: 1 } as const;
type Direction = typeof Direction[keyof typeof Direction]; // 0 | 1
Its value type is 0 | 1 — the same literal union an enum would give — and it emits const Direction = { Up: 0, Down: 1 };, an ordinary object and nothing more. You get the named constants and the narrow type, and the type half vanishes at erasure the way the rest of the type language does. That trade is why a large amount of TypeScript in the wild reaches for as const unions and leaves enum alone.
The type with every value, and the type with none
Two variables, each able to hold any value at all. Reach into both and predict which lines survive the checker.
const payload: unknown = JSON.parse("{}");
const loose: any = JSON.parse("{}");
payload.user; // ?
loose.user.name.toUpperCase(); // ?
$ tsc --ignoreConfig --strict --noEmit topbottom.ts
topbottom.ts(3,1): error TS18046: 'payload' is of type 'unknown'.
One error, and it is the unknown. The any line — a property access three levels deep on a value the checker knows nothing about — passed in silence. Both types accept every value; they could not behave less alike, and the gap between them is the point.
A top type is one that every value is assignable to. unknown and any are both tops: anything goes in. What separates them is what comes back out. unknown is the safe top. A value of type unknown could be anything, so the checker refuses to let you do anything with it until you prove what it is — assign it to a number and it stops you, read a property off it and it stops you:
topbottom.ts(2,7): error TS2322: Type 'unknown' is not assignable to type 'number'.
Everything flows into unknown; nothing flows out of it without a narrowing step (chapter 6). That is exactly what you want at the edge of a program, where a value arrives with no credentials.
any is the unsafe top, and "unsafe" is precise rather than pejorative. any is assignable to and from everything — a value of type any can be handed to a number, a string, anything, and every operation on it is permitted and unchecked. It does not merely accept values; it spreads. The result of any expression touching an any is itself any, so loose.user.name.toUpperCase() type-checks: each step returns any, and the checker is switched off for the whole chain. The file above compiled with zero errors on those lines. Reaching for any turns off the tool you are paying for, exactly where you reached.
Two narrower tops sit below unknown, and both are widely misread. {} is not "the empty object." It is "any value that is not null or undefined":
topbottom.ts(7,7): error TS2322: Type 'null' is not assignable to type '{}'.
topbottom.ts(8,7): error TS2322: Type 'undefined' is not assignable to type '{}'.
42 and "s" are both assignable to {}; only the two nullish values are rejected. object, its close cousin, means "any non-primitive": it takes { a: 1 } and refuses 42. These two look like trivia until chapter 13, where NonNullable<T> turns out to be defined as literally T & {} — intersect with "non-nullish" and the null drops out.
Now the other end. never is the bottom type: the type with no values. It is the exact mirror of unknown. Where every value is assignable to unknown, never is assignable to everything, and nothing is assignable to it:
$ tsc --ignoreConfig --strict --noEmit never_bottom.ts
never_bottom.ts(5,7): error TS2322: Type '42' is not assignable to type 'never'.
A never slot accepts a number, a string, a { x: number } — all clean — because a value that cannot exist can stand in for anything. But const d: never = 42 fails: there is no way to produce a never. And because a union is "one of these" and never contributes no value to choose, it vanishes from any union it joins — string | never is just string, which the reveal confirms:
topbottom.ts(4,36): error TS2322: Type 'string' is not assignable to type 'null'.
The type of x came back string; the | never collapsed to nothing.
unknown top: every value fits in;
│ fits into nothing until you narrow
{} every value EXCEPT null / undefined
/ \
object number, string, boolean, ...
{a:1}, [] (the primitives)
\ /
never bottom: fits into every type;
no value fits into it
any · outside the order — assignable to AND from everything,
and it switches the checker off wherever it flows.
never looks like a curiosity — who declares a variable that can hold nothing? But two later features are built on it, so it earns its place here and not a footnote. Chapter 6 checks that a switch handled every case by assigning the leftover to a never: if a case slipped through, the leftover is not never, and the checker names the type you forgot. Chapter 10 leans on never again, because a conditional type distributing over the empty union produces never in a way that decides how the whole feature behaves. Both need it in hand, so you have it now.
One more, easy to confuse with never: void. It is not the bottom type and not exactly undefined. void is the type of "a return value nobody should look at," and it has one genuinely surprising rule that chapter 9 opens on. For now it is enough that it means ignored, not nothing.
The checker follows your control flow
This function takes a string | number and calls a string-only method on it. That should be an error, and it isn't.
function ban(x: string | number) {
if (typeof x === "string") {
x.toUpperCase(); // no error — x is string in here
}
}
The checker read your runtime typeof x === "string" test and understood that inside the branch, x can only be a string. This is control-flow analysis: a variable's type is not fixed at its declaration, it is recomputed at every point in the code, following the same branches the running program will follow. The value-level if you wrote to guard the code at runtime doubles as a type-level refinement at compile time. Refining a type down a branch like this is called narrowing.
A handful of runtime tests narrow, each suited to a different kind of value. typeof splits primitives apart. instanceof splits by class — and, from chapter 1, only by class, since an interface has no value to test against. in asks whether a property is present. And the one you reach for most, the discriminant: a shared field holding a different literal in each member of a union, there to tell the members apart. Give a union that field and it becomes a discriminated union:
type Shape =
| { kind: "circle"; r: number }
| { kind: "square"; s: number }
| { kind: "tri"; b: number; h: number };
Switch on kind and each case narrows the value to exactly one member — sh.r is available under "circle", sh.s under "square". This is why chapter 4 made a fuss about widening: if kind had widened to string, there would be nothing to discriminate on. And now the exhaustiveness check chapter 5 promised. Handle two of the three cases, and in the default assign the leftover to a never:
default: { const _exhaustive: never = sh; return _exhaustive; }
$ tsc --ignoreConfig --strict --noEmit exhaustive.ts
exhaustive.ts(9,22): error TS2322: Type '{ kind: "tri"; b: number; h: number; }' is not assignable to type 'never'.
Had every case been handled, sh in the default would be narrowed to never, and the assignment would pass. Because "tri" slipped through, sh is still that member, is not assignable to never, and the checker names the exact variant you forgot. Add a fourth shape next year and this line breaks until you handle it — a compile-time exhaustiveness check built from nothing but the bottom type.
When no built-in test fits the question, you write the narrowing yourself. A type guard is a function whose return type is written x is T:
function isString(x: unknown): x is string { return typeof x === "string"; }
if (isString(val)) val.toUpperCase(); // val: string inside the branch
The x is string is a promise to the checker: if this returns true, treat the argument as a string from here on. An assertion function is the same idea for code that throws rather than returning a boolean — its return type is asserts x is T, and everything after the call is narrowed:
function assertDefined<T>(x: T): asserts x is NonNullable<T> {
if (x == null) throw new Error("nullish");
}
assertDefined(maybe);
maybe.toUpperCase(); // maybe: string, past this line
A trap with assertion calls
An assertion narrows only if the checker can name the thing being called with an explicit type, so calling one through an inferred const fails at the call site, not the definition:
$ tsc --ignoreConfig --strict --noEmit assert_callsite.ts
assert_callsite.ts(5,1): error TS2775: Assertions require every name in the call target to be declared with an explicit type annotation.
All of this makes the checker feel like it is proving things about your program. It is not, and this is the most useful fact to hold about it: control-flow analysis is a heuristic, not a proof. It errs in both directions, and a single file shows both.
First, it throws away a narrowing that was valid. Narrow a let to non-null, capture it in a closure, then reassign it afterwards:
function closureLost(x: string | null) {
if (x !== null) {
const cb = () => x.toUpperCase(); // is x still narrowed here?
x = null;
return cb;
}
}
narrowing_discard.ts(8,22): error TS18047: 'x' is possibly 'null'.
Inside the closure, x is "possibly null" again. The checker saw the later x = null, reasoned that the closure might run after it, and refused to trust the narrowing — though cb plainly closed over an x that was non-null when captured. It discarded a narrowing that would have held.
Second, in the same file, it keeps a narrowing a mutation could have broken. Narrow a property, call an unrelated function, use the property again:
function propAfterCall(o: { v: string | null }) {
if (o.v !== null) {
o.v.toUpperCase(); // narrowed to string
console.log("hi"); // could this have set o.v = null?
o.v.toUpperCase(); // still string — no error
}
}
No error on the second use. Yet console.log, or any function holding a reference to o, could in principle have set o.v = null between the two lines. The checker assumes nothing aliased o and mutated it, and holds the narrowing. Here it is optimistic where the closure case was conservative.
Put the two side by side and the shape is plain: the closure case refuses something safe, the property case allows something unsafe. Control-flow analysis is a genuinely useful account of how types move through your branches, and it is not a soundness guarantee. Treat it as a sharp assistant, not a theorem prover.
When you know something the checker cannot see, as is the honest way to say so — you assert a type and take responsibility for it. Honest, but not a cast: it changes what the checker believes and touches nothing at runtime. Chapter 1's assertion, emitted and run:
$ node as_cast.js
typeof n at runtime: string
n + 1 = 1231
"123" as unknown as number compiled; at runtime the value is still the string "123", so n + 1 is "1231", string concatenation. as moved a number through the type system and left a string in the program. That is fine when the claim is true and a silent lie when it is not — which is chapter 15, where the values making the claims arrive from outside your program.
A generic is a function in the type language
You write generics every day and read them as "a function that works for any type." That is true, and it undersells what is happening. Look at the shape of one:
function identity<T>(x: T): T { return x; }
There are two functions here, not one. identity is a function over values — give it a value, get one back. The <T> makes it also a function over types — give it a type, get a specialized signature back. identity<string> is that second function called with string, producing the signature (x: string) => string. A type parameter is a parameter to a type-level function, and instantiating a generic is calling it. This is the first concrete sign of the thesis: the type language is a real language, and here are its functions.
Value-level functions constrain their parameters with types. Type-level functions constrain their parameters with extends:
function first<T extends { length: number }>(x: T): number { return x.length; }
T extends { length: number } is the type of the type parameter — it bounds what T may be, and, more usefully, tells the checker what it may do with a T inside the body. A bare <T> is shorthand for <T extends unknown>: T could be any type, so you can do almost nothing with a value of type T, the same paralysis unknown imposed in chapter 5. The constraint is what buys you .length.
You rarely call these type-level functions by hand, because the checker calls them for you. Write identity(5) with no type argument and it infers T = number by matching the argument's type against the parameter's — a small solve, run at every call. Inference is why generics feel invisible: the type-level function is being invoked on every line, you never spell the argument.
It stops feeling invisible when a type parameter appears in two places and the two disagree about what it should be. Consider a function that takes a list of colors and a fallback drawn from the same set:
function paint2<C extends string>(colors: C[], fallback: C): C[] { return colors; }
const r = paint2(["red", "green"], "blue");
You meant C to come from colors, and fallback to be checked against it. But fallback is also a C position, so it feeds inference too, and C widens to include "blue":
$ tsc --ignoreConfig --strict --noEmit generics_infer.ts
generics_infer.ts(5,7): error TS2322: Type '("blue" | "green" | "red")[]' is not assignable to type 'null'.
C was inferred as "blue" | "green" | "red" — the fallback quietly joined the very set it was supposed to be validated against, and the mistake goes uncaught. NoInfer<T> (TypeScript 5.4) marks a position as "check here, but do not infer from here":
function paint<C extends string>(colors: C[], fallback: NoInfer<C>): C[] { return colors; }
paint(["red", "green"], "blue");
generics_infer.ts(2,25): error TS2345: Argument of type '"blue"' is not assignable to parameter of type '"green" | "red"'.
Now C is inferred from colors alone — "green" | "red" — and "blue" is correctly rejected as a fallback that is not one of the colors.
Inference also decides how wide a type comes out, and it widens by default, the way let did in chapter 4. Pass a mixed array and the element types widen:
$ tsc --ignoreConfig --strict --noEmit const_type_param.ts
const_type_param.ts(4,48): error TS2322: Type '(string | number | boolean)[]' is not assignable to type 'null'.
A const type parameter (TypeScript 5.0) makes inference behave as if the argument were written with as const, per parameter:
function asTuple<const T extends readonly unknown[]>(t: T): T { return t; }
const_type_param.ts(2,48): error TS2322: Type 'readonly [1, "a", true]' is not assignable to type 'null'.
Same call, and now T is the tuple readonly [1, "a", true] instead of the widened array — the chapter-4 widening story, moved onto the type parameter and switched on where you need it. Type parameters take defaults too: interface Box<T = string> resolves T to string when you write Box with no argument, and b.value is a string.
So the type language has functions, with parameters, bounded argument types, call-by-inference, and defaults. A language with functions is a language you can compute in. The next four chapters are that computation: the type-level if, string manipulation, iteration over keys, and the standard library written in all three.
Variance is structural typing reaching into arguments
A Dog is an Animal, so a Dog[] should be usable as an Animal[]. TypeScript agrees, and here is where that goes wrong. This whole file type-checks with no errors:
class Animal { move(): void {} }
class Dog extends Animal { bark(): void { console.log("woof"); } }
class Cat extends Animal { meow(): void { console.log("meow"); } }
const dogs: Dog[] = [new Dog()];
const animals: Animal[] = dogs; // Dog[] used as Animal[] — compiles
animals.push(new Cat()); // push a Cat through the Animal[] view
for (const d of dogs) d.bark(); // every dog barks — right?
$ tsc --ignoreConfig --strict --noEmit variance_run.ts
$ echo $?
0
Clean. Now run it:
$ node variance_run.js
woof
TypeError: d.bark is not a function
The first dog barked. The second element of dogs is the Cat you pushed in through the animals alias, and Cat has no bark. A fully type-checked, strict-mode program crashed on a method the type system swore was there.
Variance is the name for what just happened: the rule relating a wrapper's assignability to its parameter's. It is not a separate system bolted onto the type checker — it is the structural assignability of chapter 3, asked about a position. Is Dog[] assignable to Animal[]? Is (x: Dog) => void assignable to (x: Animal) => void? Same question as "is this shape assignable to that one," reaching one level down into an array element or a function parameter.
For arrays, TypeScript answers that Dog[] is an Animal[] — the array type varies the same direction as its element, which is covariance. Convenient, and unsound, because arrays are mutable. Reading a Dog out as an Animal is perfectly safe; the push is the problem, since through the Animal[] view you may write any animal into an array the rest of the program believes holds only dogs. TypeScript took the trade knowingly: array covariance makes everyday code read well, at the cost of the crash you just saw.
Function parameters vary the other way, and this one the checker does enforce. A function is assignable to a slot when its parameters are equally wide or wider — contravariance. Put a function that accepts only Dog into a slot typed to accept any Animal, and it is rejected, because the slot may hand it a Cat:
$ tsc --ignoreConfig --strict --noEmit variance.ts
variance.ts(5,5): error TS2322: Type '(d: Dog) => void' is not assignable to type 'AnimalFn'.
Types of parameters 'd' and 'a' are incompatible.
Property 'bark' is missing in type 'Animal' but required in type 'Dog'.
AnimalFn is (a: Animal) => void; a (d: Dog) => void cannot fill it, because it would call .bark() on whatever arrives, and an Animal has no bark. That is the sound rule, and strictFunctionTypes (part of strict) turns it on.
With one deliberate hole. Write the identical mismatch as a method instead of a function-typed property, and it is waved through:
interface Cmp { compare(a: Animal): void; }
const c: Cmp = { compare(d: Dog) { d.bark(); } }; // no error, even under strict
Method parameters are bivariant — assignable in either direction — always, even in strict mode. strictFunctionTypes tightens function-typed properties and pointedly leaves methods alone, because arrays, the DOM, and countless existing APIs declare their callbacks as methods and would become unusable if this were enforced. It is a chosen ergonomic hole, and knowing it is there is the point.
The sibling Python guide runs this exact example and reaches the opposite verdict, which is the sharpest contrast in the whole arc. There, feed_all takes a list[Animal] and appends an Animal; you pass it a list[Dog]; and mypy refuses:
variance.py:9: error: Argument 1 to "feed_all" has incompatible type "list[Dog]"; expected "list[Animal]"
variance.py:9: note: "list" is invariant -- see https://mypy.readthedocs.io/en/stable/common_issues.html#variance
variance.py:9: note: Consider using "Sequence" instead, which is covariant
(That is the Python guide's recorded run, not one repeated here.) Python's list is invariant — list[Dog] and list[Animal] are unrelated — and the variance is declared in the standard library: mutable containers invariant, read-only Sequence covariant. TypeScript instead infers variance from how a type parameter is used, and chose unsound covariance for arrays. So on the identical program, mypy rejects the call that would make the write unsafe, and tsc accepts it and lets the push through to the crash.
You do not need a table of rules for this. The principle underneath every case is one line: a position you can write through must be invariant or the write is unsound, and a position you only read is safe to vary covariantly. TypeScript optimized arrays and methods for ergonomics over that principle. The rest of the type system mostly follows it — and now you know the two places it does not.
Functions are where it gets subtle
This line is in every codebase, and by the rules so far it should not type-check:
const dst: number[] = [];
src.forEach(n => dst.push(n));
Array.push returns a number — the new length. forEach wants a callback of type (value: T) => void. You are handing a function that returns a number to a slot that asks for one returning void, and the checker is fine with it. The reason is a rule that looks like a hole and is actually a decision: a () => void type accepts a function that returns anything at all. The return value is discarded, so the checker does not care what it was.
type VoidFn = () => void;
const f: VoidFn = () => 42; // returning a number where void is asked — allowed
const r = f();
$ tsc --ignoreConfig --strict --noEmit void_quirk.ts
void_quirk.ts(7,7): error TS2322: Type 'void' is not assignable to type 'null'.
f was assigned a function returning 42 without complaint, and the reveal shows r — the result of calling f through its VoidFn type — is void, not number. The return value exists at runtime and is thrown away at the type level, because void means "I will not look at what you return." Take the rule away and you could not pass dst.push, or any value-returning method, as a callback. The quirk is what makes callbacks ergonomic.
The second subtlety is for a function that genuinely has different shapes for different inputs. Reversing a string should give a string; reversing an array should give an array of the same element type. You express that with overloads — several narrow public signatures stacked over one wide implementation:
function reverse(x: string): string;
function reverse<T>(x: T[]): T[];
function reverse(x: string | unknown[]): string | unknown[] {
return typeof x === "string" ? [...x].reverse().join("") : [...x].reverse();
}
$ tsc --ignoreConfig --strict --noEmit overloads.ts
overloads.ts(7,7): error TS2322: Type 'string' is not assignable to type 'null'.
overloads.ts(9,7): error TS2322: Type 'number[]' is not assignable to type 'null'.
reverse("abc") is string and reverse([1, 2, 3]) is number[] — each call resolved to the matching overload. The implementation signature, with its untidy string | unknown[], is the body's private business: it has to be wide enough to cover every case, and callers never see it. Overloads are how a library presents a clean, input-specific face over a single messy implementation.
The third is this, which in JavaScript is not a parameter but the receiver chosen at the call site — obj.method() sets this to obj, and detaching the method loses that binding. TypeScript lets you type this as if it were a first parameter, erased at runtime but checked before it:
class Counter {
count = 0;
inc(this: Counter): void { this.count++; }
}
const c = new Counter();
const loose = c.inc;
loose();
$ tsc --ignoreConfig --strict --noEmit this_typing.ts
this_typing.ts(7,1): error TS2684: The 'this' context of type 'void' is not assignable to method's 'this' of type 'Counter'.
const loose = c.inc pulled the method off its object; calling it bare would run inc with this unbound, and this.count++ would throw at runtime. The this: Counter annotation made the checker refuse the detached call — a compile-time guard over a runtime footgun the sibling JavaScript guide covers in full. this is not in the emitted parameter list; it is a type-language annotation on a value-language convention.
Three subtleties, one theme. void's discarded return, an overload's narrow faces over a wide body, and a typed this are all the type language putting words to calling conventions that JavaScript leaves silent — what a caller may ignore, which shape it will get back, and what the receiver must be.
Conditional types are the type language's if
The type language has functions (chapter 7). Give it a branch and it has enough to compute with. A conditional type is written T extends U ? X : Y, and it reads exactly like the ternary it borrows from: if T is assignable to U, the type is X, otherwise Y.
type Label<T> = T extends string ? "text" : "other";
Label<string> is "text"; Label<number> is "other". The extends here is the assignability test from chapter 3, run at the type level and used to pick a branch. That alone is a type-level if. Two more mechanisms turn it into the workhorse of the whole back half.
The first is infer, which is pattern-matching. Inside the extends clause you can name a piece of the type you are matching against and capture it:
type ElementOf<T> = T extends (infer E)[] ? E : never;
$ tsc --ignoreConfig --strict --noEmit conditional_infer.ts
conditional_infer.ts(2,47): error TS2322: Type 'string' is not assignable to type 'null'.
ElementOf<string[]> is string. Read (infer E)[] as a pattern: "if T has the shape array of something, bind that something to E." If the match fails — ElementOf<number> — you fall to the never branch. This is destructuring, in the type language: you match a structure and pull a part out of it by name.
The second mechanism is the one that surprises people, and it is where conditional types stop resembling a plain if. When the checked type is a bare type parameter and you hand it a union, the conditional distributes: it runs on each member of the union separately, then unions the results. Watch what ToArray does to string | number:
type ToArray<T> = T extends unknown ? T[] : never;
$ tsc --ignoreConfig --strict --noEmit conditional.ts
conditional.ts(2,52): error TS2322: Type 'string[] | number[]' is not assignable to type 'null'.
Not (string | number)[], which is what you would get if the union went through in one piece. You get string[] | number[] — the conditional applied to string, applied to number, and the two results joined:
ToArray<string | number> T is a bare parameter, so it distributes:
string | number
│ │
▼ ▼
string[] number[] each member runs through T extends unknown ? T[] : never
└───┬───┘
▼
string[] | number[] the branch results are unioned back together
Distribution is a feature, not an accident — it is how a type-level function maps over a union — but you do not always want it. To switch it off, stop the type parameter from being "naked" by wrapping both sides of the test in a one-element tuple. Now the whole union is compared at once:
type ToArrayND<T> = [T] extends [unknown] ? T[] : never;
conditional.ts(4,52): error TS2322: Type '(string | number)[]' is not assignable to type 'null'.
[T] extends [unknown] gives (string | number)[] — the union passed through in one piece, no spreading. The tuple wrap is the standard switch for turning distribution off, and it is worth recognizing on sight in library code.
It is also the fix for a genuinely confusing pitfall, and the pitfall is the best demonstration of why any of this matters. Suppose you want a type that answers "is T the type never?" The obvious attempt:
type Naive<T> = T extends never ? true : false;
Test it on never and it does not give true. It gives never. The reason is that never is the empty union — the union of no types at all — and a distributive conditional runs once per union member. The empty union has no members, so the conditional runs zero times, and zero branch-results union together to never. The evidence is an asymmetry. Compare the guarded version, which wraps the test in a tuple:
type IsNever<T> = [T] extends [never] ? true : false;
conditional.ts(6,42): error TS2322: Type 'true' is not assignable to type 'null'.
IsNever<never> errored against null because it is true. Naive<never> produced no error at all — it is absent from the output above — because it evaluated to never, and never is assignable to null (chapter 5), so the reveal trick stays silent. The silence is the result: the naive test collapsed to never instead of firing. Wrapping the check in [T] extends [never] stops the distribution, so the empty-union collapse never happens, and the test actually answers the question. That is why every "is it never" check in real code carries those brackets.
So a conditional type is the type language's if; infer is its pattern-match; distribution is its map over a union. With those three you can write type-level functions that inspect and transform types, and chapter 13 will show that Exclude and Extract — two of the utility types you use constantly — are each a single distributive conditional, one line long.
Template literal types compute with strings
A template literal type interpolates types into a string the same way a template literal value interpolates values into one. Where you write `on${eventName}` to build a string at runtime, you write the same thing over types to build a string type:
type EventName<T extends string> = `on${Capitalize<T>}`;
$ tsc --ignoreConfig --strict --noEmit template.ts
template.ts(5,62): error TS2322: Type '"onClick"' is not assignable to type 'null'.
EventName<"click"> is the literal type "onClick" — not string, the exact string. The Capitalize<T> in the middle did the c → C, and it is worth pausing on, because it is one of a small family of intrinsic string types the compiler implements natively: Uppercase, Lowercase, Capitalize, Uncapitalize. They have no TypeScript definition you could read — the checker performs the transformation directly:
template.ts(1,85): error TS2322: Type '"HELLO"' is not assignable to type 'null'.
Uppercase<"hello"> is "HELLO". Chapter 13 comes back to what "intrinsic" means when it reads the standard library and finds a handful of types marked exactly this way — the ones you cannot rebuild yourself.
Building strings up is the easy half. The half that feels like a different language is taking them apart, using the infer from chapter 10 against a string pattern. Here is a type that reads a two-segment path and hands back its parts:
type ExtractParams<T> = T extends `/${infer A}/${infer B}` ? [A, B] : never;
template.ts(3,62): error TS2322: Type '["users", "42"]' is not assignable to type 'null'.
ExtractParams<"/users/42"> is the tuple ["users", "42"]. Read the pattern `/${infer A}/${infer B}` the way the checker does: match a slash, capture everything to the next slash as A, match a slash, capture the rest as B. That is parsing — at the type level, over a literal string type, before your program has run a line. The infer is the same pattern-match as an array's (infer E)[]; only the shape being matched has changed, from an array to a string.
This stops being a parlor trick the moment an API is shaped like a string, which a surprising number are. An event emitter whose handlers are named on plus the capitalized event. A router whose paths carry :id segments you want typed as parameters, not left as string. A query builder, a CSS-in-JS prop, a set of permission strings like "read:users". Before template literal types these were all typed as string, and every typo in one sailed through. Now the type can be derived from the string's structure: EventName<"click"> forces callers to "onClick" and rejects "onclick", and ExtractParams turns a route into the exact tuple of parameters it declares.
The type language can now do three things with strings — build them by interpolation, transform them with the intrinsics, and decompose them by inference. That last one, decomposition, is what makes the difference: a library can look at the string you passed and compute a type from what is inside it. Pair that with the next chapter, where a type iterates over an object's keys, and you have the machinery a modern typed API is built from — one type derived, key by key and character by character, from the shape of another.
Mapped types are the type language's object comprehension
You reach for Partial<T> and Readonly<T> without thinking. Both are built from one construction, and once you can write it you stop needing to remember which utility does what. In JavaScript, to build an object from another you loop over its keys: for (const k of Object.keys(src)) out[k] = f(src[k]). A mapped type is that loop, run over keys in type space:
type Readonly<T> = { readonly [K in keyof T]: T[K] };
Read [K in keyof T] as "for each key K in keyof T" — the type-level for…of — and the body T[K] as "the value type at that key," reached with the indexed access from chapter 2. This one says: for every key of T, keep the same value type, and add readonly. It is an object comprehension whose output is a type.
readonly and ? are the two modifiers a mapped type can attach, and the interesting move is that you can also remove them, with a - prefix. -readonly strips read-only-ness; -? strips optionality, making a property required:
type Mutable<T> = { -readonly [K in keyof T]: T[K] };
type Complete<T> = { [K in keyof T]-?: T[K] };
Mutable<{ readonly x: number }> yields { x: number }, and assigning m.x = 5 compiles — the -readonly peeled the guard off. Complete does the opposite of Partial: it forces every optional property to be present:
$ tsc --ignoreConfig --strict --noEmit mapped_concrete.ts
mapped_concrete.ts(5,7): error TS2741: Property 'a' is missing in type '{}' but required in type 'Complete<{ a?: string | undefined; }>'.
Complete<{ a?: string }> rejects {}, because -? turned the optional a into a required one.
The comprehension can rename its keys as it goes, with an as clause — and the new key is a computed string type, which is where chapter 11 pays off inside chapter 12:
type Getters<T> = { [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K] };
For each key K, this emits a key `get${Capitalize<K>}` whose value is a getter returning the original type. Getters<{ name: string }> has a getName, not a name:
mapped_concrete.ts(8,43): error TS2353: Object literal may only specify known properties, and 'name' does not exist in type 'Getters<{ name: string; }>'.
The old key is gone entirely — supplying name is the excess-property error from chapter 3, because name is no longer part of the shape. Key remapping is how a library turns one interface into a derived one: getters from fields, event handlers from event names, a validated form from a schema.
The sharp edge that lives in this chapter is readonly, and it cuts shallower than the word suggests. readonly on a property protects the binding, not what the binding points at:
interface State { readonly items: string[]; }
const s: State = { items: ["a"] };
s.items = []; // rejected
s.items.push("b"); // allowed
$ tsc --ignoreConfig --strict --noEmit readonly_shallow.ts
readonly_shallow.ts(3,3): error TS2540: Cannot assign to 'items' because it is a read-only property.
readonly_shallow.ts(6,4): error TS2339: Property 'push' does not exist on type 'readonly number[]'.
You cannot reassign s.items, but you can push into the array it holds — one level down, readonly has no reach. It is the exact shape of Object.freeze from the JavaScript world, which also stops at the surface. To make the contents immutable you need a different type, ReadonlyArray<T> (or readonly T[]), which is a mapped type over the array that removes the mutating methods — so ro.push(3) fails because push is not on the type at all. A readonly property and a ReadonlyArray are two different depths, and mixing them up is a routine bug: the field is frozen, the array inside it is wide open.
Mapped types iterate an object's keys, the modifiers add or strip readonly and ?, and as renames keys through the string machinery of chapter 11. That is a full object comprehension for types, and the next chapter shows it is exactly what half the standard library is made of.
The utility types are a standard library you could have written
Partial, Pick, Omit, ReturnType — you use them like built-in operators, and it is easy to assume the compiler implements them the way it implements +. Almost none of them work that way. They are ordinary TypeScript, written in the sublanguage of the last three chapters, and they ship in a file you can open: lib.es5.d.ts. Here are the mapped ones, straight from the 7.0.2 source, doc comments removed and each collapsed to a line:
type Partial<T> = { [P in keyof T]?: T[P]; };
type Required<T> = { [P in keyof T]-?: T[P]; };
type Readonly<T> = { readonly [P in keyof T]: T[P]; };
type Pick<T, K extends keyof T> = { [P in K]: T[P]; };
type Record<K extends keyof any, T> = { [P in K]: T; };
Every one is a mapped type from chapter 12. Partial adds ? to each property; Required strips it with -?; Readonly adds readonly; Pick maps over a chosen key set K instead of all of keyof T; Record maps over K and gives every key the same value type T. Nothing here you have not already seen written by hand.
The next group is conditional types from chapter 10:
type Exclude<T, U> = T extends U ? never : T;
type Extract<T, U> = T extends U ? T : never;
Exclude is a single distributive conditional. Over a union T, it runs on each member and drops the ones assignable to U (they become never, which vanishes from the union — chapter 5), keeping the rest. Extract is the same line with the branches swapped. Two of the utilities you reach for most are one conditional each.
Now the definition worth the whole chapter, because it shows these compose:
type Omit<T, K extends keyof any> = Pick<T, Exclude<keyof T, K>>;
Omit is not primitive. It is Pick of Exclude<keyof T, K> — the keys of T, minus the keys in K. Three simple utilities stacked: take all of T's keys, exclude the ones you name, pick what remains. Rebuild it from scratch and it behaves identically to the shipped one:
type MyExclude<T, U> = T extends U ? never : T;
type MyPick<T, K extends keyof T> = { [P in K]: T[P] };
type MyOmit<T, K extends keyof any> = MyPick<T, MyExclude<keyof T, K>>;
type PublicUser = MyOmit<User, "password">; // User = { id, name, password }
const missing: PublicUser = { id: 1 };
const leaked: PublicUser = { id: 1, name: "Ada", password: "x" };
const check: MyOmit<User, "password"> = builtinOmit; // builtinOmit: Omit<User, "password">
$ tsc --ignoreConfig --strict --noEmit build_omit.ts
build_omit.ts(10,7): error TS2741: Property 'name' is missing in type '{ id: number; }' but required in type 'PublicUser'.
build_omit.ts(11,50): error TS2353: Object literal may only specify known properties, and 'password' does not exist in type 'PublicUser'.
PublicUser requires id and name, rejects password — and the check line, assigning a built-in Omit value into your MyOmit slot, drew no error at all: the two types are the same type. You just wrote Omit.
One more one-liner pays off a promise from chapter 5:
type NonNullable<T> = T & {};
{} means "any non-nullish value," so intersecting T with it removes null and undefined, which have nowhere to go in that intersection. NonNullable<string | null | undefined> is string — the reveal confirms it — from a definition four characters long.
The infer utilities extract a piece of a function or promise type. ReturnType<T> = T extends (...args: any) => infer R ? R : any matches a function and captures its return. And the recursive one, Awaited, is the type-level twin of await:
type Awaited<T> = T extends null | undefined ? T :
T extends object & { then(onfulfilled: infer F, ...args: infer _): any; } ?
F extends ((value: infer V, ...args: infer _) => any) ?
Awaited<V> :
never :
T;
It looks for a then method, infers the type its callback receives, and calls itself on that — so a Promise<Promise<string>> unwraps all the way to string, exactly as await flattens nested promises at runtime in the JavaScript guide. A conditional type calling itself is a recursive function in the type language; there is nothing else new in it.
A few types on that page defeat this reading, and the source is honest about which:
type Uppercase<S extends string> = intrinsic;
type Capitalize<S extends string> = intrinsic;
type NoInfer<T> = intrinsic;
intrinsic means "the checker does this in Go; there is no TypeScript to read." These — the string-case transforms from chapter 11, and NoInfer from chapter 7 — are the handful you genuinely cannot rebuild. Everything else on the page you now can.
The practical companion to all this is choosing between an index signature and Record, which look interchangeable and are not. An index signature { [k: string]: number } accepts any key and makes every access type-check. Record over a finite union pins the keys down:
$ tsc --ignoreConfig --strict --noEmit index_vs_record.ts
index_vs_record.ts(8,7): error TS2741: Property 'b' is missing in type '{ a: number; }' but required in type 'Rec'.
Record<"a" | "b", number> demands both a and b — it is a mapped type over exactly those two keys, so a missing one is an error. The index signature would have accepted { a: 1 }, and d.anything, and every typo besides. Use Record with a finite key union when the keys are known; reach for an index signature only when they truly are open-ended.
The utility types are a standard library written in the language of chapters 10 through 12. Reading them is how you learn to write the one you need that does not ship — and you will need one, the day a mapped type, a conditional, and an infer have to combine into something the library authors did not foresee.
interface and type are not the same tool
For an object shape, interface User { id: number } and type User = { id: number } are interchangeable — same shape, same assignability, and you can pick by taste. That taste stops being free the moment a name appears twice. Declare an interface two times and the declarations merge:
interface Box { a: number; }
interface Box { b: number; }
const box: Box = { a: 1, b: 2 }; // Box now has both — and requires both
$ tsc --ignoreConfig --strict --noEmit merging.ts
$ echo $?
0
Clean: the two Box declarations combined into one interface with both members. Do the same with a type alias and it is a name collision:
type Y = { a: number };
type Y = { b: number };
$ tsc --ignoreConfig --strict --noEmit type_dup.ts
type_dup.ts(1,6): error TS2300: Duplicate identifier 'Y'.
type_dup.ts(2,6): error TS2300: Duplicate identifier 'Y'.
This is declaration merging, and it is not a quirk to route around — it is the mechanism behind one of TypeScript's most useful capabilities: extending types you do not own. When you write ambient declarations in a .d.ts file to describe an untyped JavaScript library, or add a property to a library's interface, you are relying on interfaces merging. And the merge reaches across files, through declare module. Here one file adds a member to another module's interface:
// augment_app.ts
import { session } from "./augment_lib"; // library declares Session { userId: number }
declare module "./augment_lib" {
interface Session { role: string; } // reach in and add a member
}
const role: string = session.role; // now valid, from a different file
const bad = session.nonsense; // still an error
$ tsc --ignoreConfig --strict --noEmit augment_app.ts
augment_app.ts(10,21): error TS2339: Property 'nonsense' does not exist on type 'Session'.
Only nonsense errored. session.role type-checked, though role was declared in a different file from Session itself — the augmentation merged into the library's interface. This is how you add user to Express's Request, or a property to the global Window: the type is open, and merging is the door. A type alias is closed, and cannot be reopened this way.
The merge is not limited to interfaces with themselves. A name can occupy the value and type spaces of chapter 1 at once and merge across them — a function and a namespace of the same name coexist:
function greet(name: string): string { return `hi ${name}`; }
namespace greet { export const version = "1.0"; }
const gv: string = greet.version; // greet is callable AND carries members
greet is a function you can call and an object with a version — the same dual-space trick a class plays, assembled by hand.
The two tools also build on other types differently. An interface uses extends; a type uses &, intersection:
interface Admin extends User { level: number; }
type Admin = User & { level: number; };
For object shapes these mostly agree. The real divide is what each can express. An interface describes an object shape and nothing else — it cannot be a union, a tuple, a conditional, or a mapped type. A type can be any of those. A union like "circle" | "square", an Awaited<T>, a Record<K, V> — none of them is an object shape, so none can be an interface. Everything from the last four chapters lives on the type side of the line, because it is computation, and interfaces do not compute.
So the choice is not stylistic once you know what turns on it. Reach for interface when the thing is an object shape that consumers might extend or a library you might augment — its merging is a feature exactly there. Reach for type when the thing is a union, a tuple, or anything built with generics, conditionals, or mapped types — the computed forms an interface cannot hold. A useful default falls out of that: interfaces for the public object shapes at your boundaries, type for everything the type language computes in between.
A green check is a promise about code you wrote, not data you receive
Here is a request payload whose id arrived as a string, an off-by-one in some upstream service, asserted to be a User whose id is a number. It compiles, and then it runs:
const payload: unknown = JSON.parse('{"id": "not-a-number", "name": "Ada"}');
const lied = payload as User; // User = { id: number; name: string }
$ node zod_demo.js
as-cast, typeof lied.id: string
lied.id is typed number and is a string at runtime. The checker did not miss anything: it never had the data. From chapter 1, types are erased: at runtime there is no User, no number, nothing to test the payload against. A value crossing into your program from JSON.parse, a fetch response, process.env, a database row, from anywhere you did not write, carries the type you asserted for it, not one anything verified. as is the assertion, and at the boundary it is a lie you have not been caught in yet. The crash comes later, in some function that trusts id is a number and does arithmetic on a string.
This is the fact the sibling Python guide states outright, and it holds identically here: a green type check and a working program are two independent facts. The checker guarantees the code you wrote is consistent with itself. It cannot guarantee that the world matches the claims your code makes about it, because it checked the claims, not the world.
Before the fix, sort out the three ways to attach a type to a value, because two of them are routinely confused and only one is right for trusted data. Take a config object you are writing by hand. A bare annotation checks it and widens it:
type Config = Record<string, number | string>;
const c2: Config = { port: 8080, host: "localhost" };
c2.port.toFixed(0);
$ tsc --ignoreConfig --strict --noEmit satisfies.ts
satisfies.ts(5,9): error TS2339: Property 'toFixed' does not exist on type 'string | number'.
The annotation forced c2 to be a Config, so port is now number | string — you lost the fact that it is a number, and toFixed is gone. as would go the other way: it changes the type to whatever you claim and checks nothing, the lie from above. satisfies (TypeScript 4.9) is the third option, and the one you want here — it checks the value against the type but leaves the value's own, narrower inferred type in place:
const c1 = { port: 8080, host: "localhost" } satisfies Config;
c1.port.toFixed(0); // fine — port is still number
c1.port stayed number, so toFixed works, and the check still fires: a typo in a key is caught where as would have swallowed it:
satisfies.ts(7,31): error TS2561: Object literal may only specify known properties, but 'secndary' does not exist in type 'Palette'. Did you mean to write 'secondary'?
So satisfies is the honest cousin of as: as changes the type without checking, satisfies checks without changing. And like as, it is compile-time only — it erases to nothing:
$ grep satisfies out/satisfies_erase.js
const c = { a: 1, b: 2 }; // satisfies check
Which returns us to the boundary, where the lesson bites: none of the three helps with untrusted data, because all three erase. satisfies makes your own literals precise; it does nothing to a payload the compiler never saw. The only thing that helps is to turn the compile-time claim back into a runtime check — a check written in the value language, which actually runs. A schema library does exactly that: you describe the shape as a runtime value, a schema, and check data against it. Here zod's safeParse meets the same bad payload:
zod safeParse success: false
zod issue path: ["id"] code: invalid_type message: Invalid input: expected number, received string
It caught what as waved through, at the exact path, because the check exists at runtime as a value rather than as an erased annotation. The pattern has a name — parse, don't validate. A validator returns a boolean and leaves you holding an unknown you must still assert; a parser returns a typed value on success, so past the parse you have a User the type system and the runtime finally agree on. One function both proves the shape and hands you the narrowed type — no as required, because the check earned it. (The library is incidental; the boundary and the pattern are the point.)
The checker's promise is real and it is narrow: the code you wrote is internally consistent. Every place data enters is a place you signed a claim the checker took on faith, and the type language — erased before the first line runs — is not there to honor it. Honoring it is a runtime job. That is the final consequence of erasure, the one chapter 1 pointed at and every chapter since has been walking toward: the types keep your code honest with itself, and at the edges, keeping it honest with reality is a check you have to run.
strict is nine flags, and the two that change your day are off by default
"strict": true is one line in your tsconfig, and it is an alias for nine separate checks. Almost everyone flips it on and never meets the nine individually — which is fine, until you realize the more consequential fact: the two checks that would most change your code are not among them. Start with what strict does turn on, each check tied to the concept from earlier that it governs:
| Flag | Fires as | What it governs |
|---|---|---|
noImplicitAny |
TS7006 |
an untyped parameter is an implicit any (chapter 5) |
strictNullChecks |
TS2322 |
null / undefined are their own types, to be narrowed (chapters 5–6) |
strictFunctionTypes |
TS2322 |
function-parameter contravariance (chapter 8) |
strictBindCallApply |
TS2345 |
argument types through call / bind / apply |
strictPropertyInitialization |
TS2564 |
a class field must actually be assigned |
noImplicitThis |
TS2683 |
this needs a type (chapter 9) |
useUnknownInCatchVariables |
TS18046 |
catch (e) binds unknown, not any (chapter 5) |
alwaysStrict |
"use strict" |
emits the strict-mode prologue in the output |
strictBuiltinIteratorReturn |
— | a built-in iterator's TReturn is undefined, not any (since 5.6) |
Every error code there is from a run on 7.0.2. strictNullChecks is the one carrying the most weight: without it, null and undefined are silently members of every type, and the entire narrowing story of chapter 6 has nothing to bite on. The last, strictBuiltinIteratorReturn, is the newest — the 5.6 notes describe it flipping the iterator return type: "By default, BuiltinIteratorReturn is any, but when --strictBuiltinIteratorReturn is enabled (possibly via --strict), it is undefined."
Now the two that are off even under strict, and change real code the most. The first is noUncheckedIndexedAccess. Index into an array or a record and, by the rules so far, you get the element type back cleanly — arr[10] is number, guaranteed. But arr[10] might not be there, and the type quietly lies about it. Turn the flag on and every index yields T | undefined:
$ tsc --ignoreConfig --strict --noUncheckedIndexedAccess --noEmit unchecked_index.ts
unchecked_index.ts(3,1): error TS18048: 'x' is possibly 'undefined'.
unchecked_index.ts(6,1): error TS18048: 'y' is possibly 'undefined'.
It fires on both array access (arr[10]) and record access (rec.missing), forcing you to handle the absent case at every lookup. It is off by default because it makes indexing noisier, since you must narrow away undefined everywhere, and that was judged too disruptive to force on. What it catches in exchange is the cannot read properties of undefined crash — the bug that otherwise surfaces only when the code runs.
The second is exactOptionalPropertyTypes. Without it, an optional a?: number also silently permits a: undefined set explicitly. With it, "optional" means absent, and passing undefined outright is an error:
$ tsc --ignoreConfig --strict --exactOptionalPropertyTypes --noEmit exact_optional.ts
exact_optional.ts(2,7): error TS2375: Type '{ a: undefined; }' is not assignable to type 'O' with 'exactOptionalPropertyTypes: true'. Consider adding 'undefined' to the types of the target's properties.
The distinction between "missing" and "present but undefined" is exactly the kind of thing that decides whether a key in obj check or a spread behaves the way you assumed. Turn both of these on in any codebase you care about; strict alone is not the strictest honest setting.
The last thing a reader hits is not a type-system change at all: it is the TypeScript 7 configuration landscape. TS 7 is the compiler rewritten in Go, and its stated aim is parity with the 6.0 checker:
Same types as TS 6.0. Same errors, locations, and messages as TS 6.0.
So nothing this guide taught is "new in 7"; the type system is the shared core across 5.x, 6, and 7. What changed on upgrade is defaults and removals. strict is now on with no flag at all:
$ tsc --ignoreConfig --noEmit implicit_any.ts
implicit_any.ts(1,12): error TS7006: Parameter 'x' implicitly has an 'any' type.
module now defaults to ESM — export const greeting = "hi" emits as export const greeting = "hi";, not a CommonJS exports. And several long-deprecated options are gone outright, which is what actually breaks a build:
$ tsc --ignoreConfig --target es5 --noEmit implicit_any.ts
error TS5108: Option 'target=ES5' has been removed. Please remove it from your configuration.
$ tsc --ignoreConfig --module none --noEmit implicit_any.ts
error TS6046: Argument for '--module' option must be: 'commonjs', 'es6', 'es2015', 'es2020', 'es2022', 'esnext', 'node16', 'node18', 'node20', 'nodenext', 'preserve'.
target: es5 and moduleResolution: node10 are removed, the legacy amd/umd/system/none module formats with them, and — the one that catches you on day one — passing a file on the command line while a tsconfig.json is in scope is now an error (TS5112, the reason every single-file check in this guide passed --ignoreConfig). None of it touched a type. The upgrade to 7 is a compiler swap and a config cleanup, not a language change.
Which is the whole guide in one line. From the first chapter, TypeScript is two languages sharing one syntax, and one of them is erased before anything runs. Structural typing, narrowing, generics and their variance, the type-level functions of chapters 10 through 12, the standard library you can now read and write, the boundary where a green check stops being a guarantee — every one of them is a consequence of taking those two facts seriously: the type language is a real, computable language, and it is gone by the time your program executes. strict and its off-by-default companions are the dial on how hard that language works for you in the window it has. Turn it up. The checker only ever helps you at your desk; skip a check there, and reality asks for it at runtime instead — where the types are gone, and cannot help you.
Further reading
The primary sources behind this guide. Every claim in the text was run on TypeScript 7.0.2 and Node 22.22.2, or read against one of these.
The language — the TypeScript Handbook. The reference for everything taught here: Everyday Types and Narrowing (chapters 3–6), More on Functions (9), Object Types and the Type Manipulation series — Generics, Keyof, Typeof, Indexed Access, Conditional, Mapped, and Template Literal Types (chapters 2, 7, 10–12). Its Enums page carries the "const enum pitfalls" behind chapter 4.
The compiler — the tsconfig reference. Every flag in chapter 16: strict and its members, and the opt-ins (noUncheckedIndexedAccess, exactOptionalPropertyTypes) enumerated with what each turns on.
The standard library — lib.es5.d.ts, shipped inside the compiler. The utility types of chapter 13 are ordinary TypeScript you can open and read; on 7.0.2 they live in node_modules/@typescript/typescript-linux-x64/lib/, where Omit, NonNullable, Awaited and the rest are defined in a few lines each.
TypeScript 7 — the typescript-go README. The native (Go) compiler's identity and its parity target, in its own words: "Same types as TS 6.0," "Same errors, locations, and messages as TS 6.0." The release notes date each version-gated feature — satisfies (4.9), const type parameters (5.0), NoInfer (5.4), strictBuiltinIteratorReturn (5.6).
The sibling guides. Python, from the object up is the type-system foil throughout — erasure against Python's kept annotations, structural typing against Protocol, and the variance example the two checkers rule on oppositely. JavaScript, from the host up is the runtime this language erases down to: the emitted JavaScript in chapter 1 is that language, and the boundary in chapter 15 is where its JSON.parse and fetch values arrive with types nothing verified. Comparisons, not prerequisites.