- Book: TypeScript Essentials — From Working Developer to Confident TS
- Also by me: The TypeScript Library — the 5-book collection
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
You parse a webhook payload. The variable comes back as
unknown, the compiler refuses to let you read .event off it,
and the deadline is in twenty minutes. So you write
(payload as WebhookEvent).event and move on.
That cast is a promise you made to the compiler with no proof
behind it. It says "trust me, this is a WebhookEvent." The
compiler stops checking. Three weeks later the upstream service
renames a field, the payload no longer matches, and your code
reads undefined off an object you swore was the right shape.
The cast did exactly what you told it to: it shut the compiler
up.
Narrowing is the alternative. Instead of asserting a type, you
prove it at runtime, and TypeScript's control-flow analysis
follows along, narrowing the static type to match the check you
just ran. The proof and the type stay in lockstep. Here are five
patterns that turn as into a real test.
1. typeof for primitives
The most common cast is the laziest: you have a string | number
and you want to treat it as one or the other. A typeof check is
all the proof the compiler needs.
function format(value: string | number): string {
if (typeof value === "string") {
return value.trim(); // value: string
}
return value.toFixed(2); // value: number
}
Inside the if, value is string. In the return below it,
the compiler has eliminated string from the union, so value
is number and .toFixed is legal. No cast, and the check is
real code that runs.
typeof narrows on each of its eight possible string results:
"string", "number", "boolean", "bigint", "symbol",
"undefined", "function", and "object". The trap is
"object": it matches null, arrays, and every object alike, so
it narrows less than people expect. For anything past primitives,
you reach for the next four patterns.
2. instanceof for classes and errors
The place this earns its keep is catch. Under modern strict
settings the caught value is typed unknown, which is correct:
anything can be thrown. Most code casts it back to Error and
hopes. An instanceof check proves it.
try {
await chargeCard(order);
} catch (err) {
if (err instanceof StripeCardError) {
return retryWith(err.declineCode); // err: StripeCardError
}
if (err instanceof Error) {
log(err.message); // err: Error
throw err;
}
throw new Error(`Non-error thrown: ${String(err)}`);
}
Each branch narrows err from unknown to the matched class.
You get err.declineCode in the first branch and err.message
in the second with no cast, because instanceof is a real
prototype check the runtime performs.
One caveat: instanceof works on prototype chains, so it breaks
across realms (an Error from another iframe or worker is not
your Error) and against transpiled classes whose prototype was
not set up. For the common case (your own classes and built-in
error types in one runtime) it is exact.
3. The in operator for shape
When you have a union of object types and no shared class, the
in operator narrows by checking for a property. This is the one
that replaces casts in code dealing with polymorphic API
responses.
interface SuccessResult {
data: User[];
}
interface ErrorResult {
message: string;
code: number;
}
function handle(res: SuccessResult | ErrorResult) {
if ("data" in res) {
return res.data.length; // res: SuccessResult
}
return `${res.code}: ${res.message}`; // res: ErrorResult
}
"data" in res is a runtime check that returns a boolean, and
the compiler treats a true result as proof that res is the
member of the union that has a data property. The else branch
narrows to ErrorResult automatically.
This works, but it leans on structural accidents — which property
happens to exist on which member. The moment two members share a
field, in gets ambiguous. The next pattern fixes that by design.
4. Discriminated unions
A discriminated union gives every member a shared literal field
(the discriminant), so one check decides the whole shape. This is
the pattern worth restructuring your types around, because it
turns narrowing into a switch the compiler can check for
exhaustiveness.
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;
default:
return assertNever(shape);
}
}
function assertNever(x: never): never {
throw new Error(`Unhandled: ${JSON.stringify(x)}`);
}
Switching on shape.kind narrows shape to the exact member in
each case. shape.radius is reachable only inside "circle",
where it exists. No casting between members, ever.
The default branch is the part that pays off later. By the time
control reaches it, every known kind is handled, so shape has
narrowed to never. Add a fourth shape to the union and forget a
case, and shape is no longer never at the default — the
call to assertNever fails to compile. The compiler turns a
missing case into a build error instead of a runtime surprise.
5. Type predicates and assertion functions
The four patterns above narrow inline. Sometimes you want to
extract the check into a reusable function — and a plain
boolean return throws the narrowing away the moment you cross
the function boundary. Two return-type annotations fix that.
A type predicate (x is T) tells the compiler that a true
return proves the argument is T:
interface User {
id: string;
email: string;
}
function isUser(value: unknown): value is User {
return (
typeof value === "object" &&
value !== null &&
"id" in value &&
"email" in value &&
typeof (value as Record<string, unknown>).id === "string"
);
}
const payload: unknown = await req.json();
if (isUser(payload)) {
sendWelcome(payload.email); // payload: User
}
The value as Record<string, unknown> inside the guard is the
last cast you write. It is contained, audited, and sits
next to the runtime check that backs it. Every caller of isUser
narrows for free with no cast at all.
An assertion function (asserts x is T) is the variant that
throws instead of returning a boolean. It narrows everything
after the call:
function assertIsUser(
value: unknown,
): asserts value is User {
if (!isUser(value)) {
throw new TypeError("Expected a User");
}
}
const payload: unknown = await req.json();
assertIsUser(payload);
sendWelcome(payload.email); // payload: User from here down
After assertIsUser(payload) returns, the compiler knows
payload is User for the rest of the scope. No if, no cast.
The function either proves the type or stops execution.
One detail the compiler enforces: an assertion function must have
an explicit return-type annotation. Drop the asserts value is and TypeScript treats it as a normal void function — the
User
narrowing silently disappears. The annotation is the contract.
Why proof beats assertion
Line up what each pattern replaces. typeof and instanceof
delete casts on primitives and class instances. in and
discriminated unions delete casts between members of a union.
Type predicates and assertion functions delete casts at function
boundaries and move the one unavoidable cast into a single tested
place.
The difference is who does the checking. An as cast moves the
check from the compiler to nobody. It asserts a fact and removes
the guarantee that the fact is true. A narrowing check keeps the
compiler in the loop: the runtime test and the static type prove
the same thing, so they cannot drift apart.
Next time your editor underlines a property and your reflex is to
reach for as, ask what proof you actually have. If you can write
the runtime check, write it, and let control-flow analysis hand
you the type. The cast that survives that question is the rare one
worth keeping.
If narrowing clicked and you want the same treatment for the rest
of TypeScript's daily surface — the type system, modules, async,
the tooling you touch every day — that is what TypeScript
Essentials is built around. The narrowing chapter walks every
pattern above with the edge cases this post had to skip.
The TypeScript Library — the 5-book collection. Books 1 and 2 are the core path; 3 and 4 substitute for 1 and 2 if you come from the JVM or PHP; book 5 is for anyone shipping TS at work.
- TypeScript Essentials — types, narrowing, modules, async, daily-driver tooling across Node, Bun, Deno, and the browser.
- The TypeScript Type System — generics, mapped/conditional types, infer, template literals, branded types.
- Kotlin and Java to TypeScript — variance, null safety, sealed classes to unions, coroutines to async/await.
- PHP to TypeScript — the sync-to-async shift, generics, discriminated unions for PHP 8+ developers.
- TypeScript in Production — tsconfig, build tools, monorepos, library authoring, dual ESM/CJS, JSR.
All five books ship in ebook, paperback, and hardcover.

Top comments (0)