TypeScript
Structural Typing and Narrowing
TypeScript compares shapes, not names. How that decides whether your class fits an interface, and how narrowing turns one unknown value into code you can trust.
A response comes back from an API and you already know its shape, because you wrote the server. You type the result as any, read three fields off it, and the whole thing compiles. Six weeks later the server renames one field and the browser throws TypeError: Cannot read properties of undefined in a file you have not opened since.
TypeScript would have caught that, but only if the value had been typed as something it had to check. Two ideas do most of that work: the type system compares shapes rather than names, and narrowing is how you turn a value of unknown shape into code that is safe to run. Both are things you can predict once you have seen them fail.
The examples below ran on TypeScript 6.0.3 with Node 24.11.1. Where a behaviour differs in TypeScript 5.x the text says so, because the defaults changed between the two.
TypeScript matches shapes, so your class already satisfies the type
Give a function a parameter type, then hand it a class instance that never mentioned that type anywhere:
type Vector = { x: number; y: number };
class ScreenPoint {
x = 0;
y = 0;
label = "origin";
}
function translate(v: Vector, dx: number, dy: number): void {
v.x += dx;
v.y += dy;
}
const p = new ScreenPoint();
translate(p, 3, 4);
console.log(p.x, p.y, p.label);
3 4 origin
ScreenPoint has no implements Vector clause and the compiler does not care. It checks that every property Vector requires exists on the argument with a compatible type. Extra properties are irrelevant: label goes along for the ride and disappears inside translate, which is why the function can mutate x and y on an object it does not know about otherwise.
This is structural typing, and it is the opposite of how Java or C# work. In a nominal system ScreenPoint is not a Vector until somebody declares it to be one. Here the shape is the type. The practical consequence is that types cross package boundaries without importing each other. A plugin that expects { id: string } accepts an object from a library that has never heard of the plugin, and neither project needs the other as a dependency. Your types describe data, not ownership.
Two places where the structural model closes
It closes on private and protected members. A class with a private field only satisfies a type that declares a member of the same name with the same origin:
class Wallet {
private balance = 0;
deposit(amount: number) { this.balance += amount; }
}
class Counter {
private balance = 0;
deposit(amount: number) { this.balance += amount; }
}
function depositInto(w: Wallet, amount: number) { w.deposit(amount); }
depositInto(new Counter(), 10);
error TS2345: Argument of type 'Counter' is not assignable to parameter of type 'Wallet'.
Types have separate declarations of a private property 'balance'.
The error names the private property, not the method signatures that match exactly. Private and protected members carry their declaring class with them, so two classes with identical shapes are not interchangeable while either holds a private field. It is the one case where a JavaScript developer accidentally writes something nominal, and it arrives as a surprise precisely because the rest of the system has taught them that shape is everything.
Fresh literals get an extra check
An object literal written directly at a position that expects a type is checked for properties the type does not have:
type Config = { host: string; port: number };
function connect(c: Config): string {
return `${c.host}:${c.port}`;
}
console.log(connect({ host: "localhost", port: 5432, retries: 3 }));
const cfg: Config = { host: "localhost", port: 5432, retries: 3 };
const defaults = { host: "localhost", port: 5432, retries: 3 };
console.log(connect(defaults));
error TS2353: Object literal may only specify known properties, and 'retries' does not exist in type 'Config'.
error TS2353: Object literal may only specify known properties, and 'retries' does not exist in type 'Config'.
Two errors, three candidate lines. The call with the literal fails, and so does the variable annotated as Config, because the literal is still being assigned to a declared type. The third line compiles: by the time defaults reaches connect, it is a variable with an inferred type, and inference produced { host: string; port: number; retries: number }, which satisfies Config under the ordinary property rules.
This is called the excess-property check, and the compiler applies it to fresh object literals. It exists to catch misspelled keys, which is a real class of bug: { port: 5432, hosst: "localhost" } would otherwise assign cleanly and fail at runtime, since host is missing but TypeScript cannot know which properties you meant to write. The check is not a soundness rule. It is a typo detector with a scope, and passing a literal through a variable is the documented way to widen the scope.
The check travels with the expression
Freshness is a property of the expression being assigned, not of the variable. A function that returns an object literal also returns a fresh value, so connect(makeConfig()) triggers the same check when makeConfig has an explicit Config return type.
unknown forces a check, any deletes the check
JSON.parse returns any in the standard library, and that signature is a hole in the type system that spreads. Any function that touches the result becomes untyped in turn. Declare the boundary yourself and the hole closes:
function parse(json: string): unknown {
return JSON.parse(json);
}
const payload = parse('{"id":"a1","total":19.5}');
console.log(payload.id);
console.log(payload.total * 2);
error TS18046: 'payload' is of type 'unknown'.
error TS18046: 'payload' is of type 'unknown'.
unknown is the top type: every value is assignable to it, and it is assignable to nothing except itself and any. You cannot read a property from it, call it, index it, or do arithmetic with it. Every operation you want requires a check that convinces the compiler of something narrower first.
any is the off switch. It is assignable both ways, so const loose: any = parse(json) lets the two lines above compile, and loose.missing.deep compiles as well. Nothing there fails until production. Use unknown at every boundary where data enters from outside your program, and treat an any in a signature as a decision somebody has to justify. Lint rules in several ecosystems ban any in exported types for that reason.
Four ways to narrow, and one you have to trust
Narrowing is the compiler tracking what a runtime check rules out. The tools arrive in this order of usefulness.
typeof handles the primitives: typeof value === "string" removes everything that is not a string from the union. It also produces "object" for null, which is why every guard against an object starts with a null check rather than relying on the typeof result.
A discriminant property is the tool that scales. Give each member of a union a literal-typed field with a different value, and a switch on that field narrows to exactly one member per case:
type Shape =
| { kind: "circle"; radius: number }
| { kind: "square"; side: number }
| { kind: "rect"; w: number; h: number };
function area(shape: Shape): number {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2;
case "square":
return shape.side ** 2;
case "rect":
return shape.w * shape.h;
}
}
console.log(area({ kind: "circle", radius: 2 }).toFixed(2));
console.log(area({ kind: "rect", w: 3, h: 4 }));
12.57
12
Inside case "circle" the compiler knows shape.radius exists, and reaching for shape.w there is an error rather than undefined. Add a fourth shape to the union and the function stops compiling until you handle it, provided you use the exhaustiveness pattern in the lab below.
in narrows by property presence when members lack a discriminant: if ("permissions" in account) selects the member that declares permissions. It is weaker than a discriminant, because it depends on a key that no type declares as special, and it does not tell you anything about a value whose type is a single interface rather than a union.
instanceof narrows to a class, which makes it the right tool for error handling, where the thing you caught is unknown and you want to distinguish your own error classes from everything else. This pattern is worth copying:
class NotFound extends Error {
constructor(public readonly id: string) {
super(`missing ${id}`);
}
}
function statusOf(error: unknown): number {
if (error instanceof NotFound) return 404;
if (error instanceof Error) return 500;
return 500;
}
console.log(statusOf(new NotFound("o1")), statusOf(new Error("boom")), statusOf("nope"));
404 500 500
The order matters. NotFound is checked before Error because the first matching branch wins and NotFound extends Error. Reversing the two lines makes every NotFound return 500.
When none of those fit, you write the guard yourself:
function isAccount(value: unknown): value is Account {
if (typeof value !== "object" || value === null) return false;
const role = (value as { role?: unknown }).role;
return role === "admin" || role === "member";
}
The return type value is Account is a promise the compiler accepts without evidence. Inside the function body, TypeScript checks only that the return value is a boolean. A guard that returns true for everything compiles, and every caller after it is wrong with no error anywhere. Guards let you teach the compiler facts it cannot derive, and they move the verification burden onto your tests. Validate the fields you actually use rather than the whole object, and keep the guard next to the type it asserts so a rename breaks both together.
Narrowing is control flow, so it ends where the flow ends
The compiler discards what it learned at the points where it cannot prove the check still holds. A property read inside a callback is the common case:
type Session = { user?: { name: string } };
function greet(session: Session): string {
if (!session.user) return "hello, stranger";
return [1, 2].map(() => session.user.name).join(", ");
}
error TS18048: 'session.user' is possibly 'undefined'.
Between the guard and the callback the compiler assumes nothing, because the function passed to map could in principle run after session.user is reassigned. Copy the value into a local before the callback and the narrowed type survives:
function greet(session: Session): string {
const user = session.user;
if (!user) return "hello, stranger";
return [1, 2].map(() => user.name).join(", ");
}
This is usually the right fix anyway, because it reads the property once and holds one value for the whole function. A non-null assertion (session.user!) also silences the error, and keeps the reassignment hazard the warning was about.
Write a file that turns an unknown payload into a typed order, and make the compiler prove it. Create narrow.ts in a project with strict enabled and start from this:
type Line = { sku: string; qty: number };
type Order = {
id: string;
status: "draft" | "paid" | "shipped";
lines: Line[];
};
export function parseOrder(raw: unknown): Order {
throw new Error("not implemented");
}
Implement it without a single as on the way in, and throw a TypeError with a message naming the field that failed when the payload does not match. Then add shippedAt?: string to Order, write a describe(order: Order): string that mentions the shipping date only for shipped orders, and make it exhaustive with a never check in the default branch of a switch.
Answer before you run anything: which of the four narrowing tools does each check need, and which one can you not avoid?
Show the solution and the reasoning
The intermediate value is the whole design. Narrow unknown to a record once, then read fields from it with individual checks, so each error message can name the field it rejected:
type Line = { sku: string; qty: number };
type Order = {
id: string;
status: "draft" | "paid" | "shipped";
lines: Line[];
shippedAt?: string;
};
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function isLine(value: unknown): value is Line {
return isRecord(value) && typeof value.sku === "string" && typeof value.qty === "number";
}
const STATUSES = ["draft", "paid", "shipped"] as const;
function isStatus(value: unknown): value is Order["status"] {
return typeof value === "string" && (STATUSES as readonly string[]).includes(value);
}
export function parseOrder(raw: unknown): Order {
if (!isRecord(raw)) throw new TypeError("payload: expected an object");
if (typeof raw.id !== "string") throw new TypeError("id: expected a string");
if (!isStatus(raw.status)) throw new TypeError("status: expected draft, paid or shipped");
if (!Array.isArray(raw.lines) || !raw.lines.every(isLine)) {
throw new TypeError("lines: expected an array of { sku, qty }");
}
if (raw.shippedAt !== undefined && typeof raw.shippedAt !== "string") {
throw new TypeError("shippedAt: expected a string when present");
}
return {
id: raw.id,
status: raw.status,
lines: raw.lines,
shippedAt: raw.shippedAt,
};
}
isRecord is the guard that pays for itself, because it lets every later check read a property without an assertion. The typeof value !== "object" test in isolation is not enough: null passes it, and arrays pass it while having no id field worth trusting. Testing for the shape you want, rather than excluding the shapes you can think of, is the habit that keeps the guard honest.
The shippedAt field shows why optional properties need their own branch. raw.shippedAt !== undefined is true for a missing key and for an explicit undefined, so the following typeof check only runs when a value is present. The return statement then passes raw.shippedAt, which the compiler knows is string | undefined at that point, and that is exactly what the optional property accepts under the default optional-property rules. Turn on exactOptionalPropertyTypes and you have to build the object conditionally instead; lesson 15 covers why.
The exhaustiveness check needs the discriminant and a helper:
function assertNever(value: never): never {
throw new Error(`unhandled order status: ${JSON.stringify(value)}`);
}
function describe(order: Order): string {
switch (order.status) {
case "draft":
return `${order.id} is a draft`;
case "paid":
return `${order.id} is paid`;
case "shipped":
return order.shippedAt
? `${order.id} shipped on ${order.shippedAt}`
: `${order.id} shipped`;
default:
return assertNever(order.status);
}
}
The function does not compile, and the reason is the point:
error TS2345: Argument of type '"shipped"' is not assignable to parameter of type 'never'.
The default branch is reachable, so order.status keeps whatever the other cases did not remove, which is "shipped". Passing it to a parameter typed never fails, and the error names the case you forgot. Add case "shipped" and the narrowed type becomes never, so the call compiles and the branch throws only if the runtime delivers a value the types did not predict. That is why the helper takes the discriminant rather than the object: the narrowed type of order itself never becomes never, only the property you switched on does.
Dropping the helper entirely fails differently. Without a default branch, some code path returns undefined where a string was promised, and strictNullChecks reports it.
Which tool for which check. typeof covers the primitives in isRecord and the field checks in isLine. Array.isArray is not a narrowing operator but it narrows unknown to any[], which is why it is followed by every rather than trusted on its own. The discriminant is what makes describe exhaustive. The one you cannot avoid is the user-defined guard: isRecord, isLine and isStatus are all assertions the compiler cannot verify, and there is no way to validate an arbitrary object without writing at least one. Every unverified guard is a claim your tests have to hold up.
A schema library such as Zod or Valibot derives the static type from the runtime validator, so the two cannot drift apart. That costs a dependency; for a payload you control, the hand-written version is twenty lines.
Progress
Mark this lesson done and it stays marked in this browser.