You've used typeof narrowing in chapter 2 to handle string | number parameters. This lesson is the full toolbox: every way TypeScript lets you go from a broad type ("this could be a string or a number or null") to a specific type ("inside this branch, it's definitely a User"). The patterns here are how every real test framework handles mixed responses, error vs success unions, and untrusted JSON — without any, without casts, and without surprise runtime crashes.
Narrowing in one sentence
Narrowing is the compiler's ability to track that a runtime check has eliminated some possibilities. After if (typeof x === "string"), TypeScript knows that inside the if, x can only be a string. Outside it, the original union still applies.
Narrowing is what makes union types usable. Without it, every string | number would force you to cast or to handle every method as though it might be missing — exactly the world JavaScript leaves you in.
typeof — for primitives
typeof works on primitives: string, number, boolean, bigint, symbol, undefined, function, object.
function processValue(value: string | number): string {
if (typeof value === "string") {
return value.toUpperCase(); // ✅ value is string here
}
return value.toFixed(2); // ✅ value is number here
}You met this in chapter 2. Everything you've used typeof for in JavaScript still applies — TypeScript just understands the check and updates the type accordingly.
instanceof — for class instances
instanceof narrows a value to a specific class. It's the right tool when your union mixes concrete classes (Error subclasses are the canonical example):
class ApiError extends Error {
statusCode: number;
constructor(message: string, statusCode: number) {
super(message);
this.statusCode = statusCode;
}
}
function handleError(error: Error | ApiError): void {
if (error instanceof ApiError) {
console.log(`API ${error.statusCode}: ${error.message}`); // ✅ ApiError
} else {
console.log(`Error: ${error.message}`); // ✅ plain Error
}
}Inside the if, error has type ApiError — error.statusCode autocompletes. In the else branch, it's narrowed to Error.
instanceof only works for class instances. If your shapes are plain object literals (no class), use the in operator or a discriminated union instead.
The in operator — for property existence
"key" in object checks whether a property is present at runtime. TypeScript narrows the union to the variants that have that key.
interface SuccessResponse { status: 200; data: User }
interface ErrorResponse { status: 400 | 500; error: string }
type ApiResult = SuccessResponse | ErrorResponse;
function handleResult(result: ApiResult): void {
if ("data" in result) {
console.log("Success:", result.data.name); // ✅ SuccessResponse
} else {
console.log("Error:", result.error); // ✅ ErrorResponse
}
}The check distinguishes the two interfaces by a field unique to one of them. This works for any object union where each variant has at least one distinguishing property.
Discriminated unions — the cleanest pattern
The most readable narrowing pattern: give every variant a shared discriminant field — a literal-string property that identifies which variant you've got — and switch on it.
type TestEvent =
| { type: "started"; testName: string }
| { type: "passed"; testName: string; duration: number }
| { type: "failed"; testName: string; error: string };
function logEvent(event: TestEvent): void {
switch (event.type) {
case "started":
console.log(`⏳ ${event.testName}`);
break;
case "passed":
console.log(`✅ ${event.testName} (${event.duration}ms)`);
break;
case "failed":
console.log(`❌ ${event.testName}: ${event.error}`);
break;
}
}Each case narrows event to exactly one of the three variants. event.duration is only valid inside the "passed" case — outside it, the field doesn't exist and the compiler enforces that.
This pattern is the gold standard for typed event streams, success/error results, and any union where the variants are conceptually distinct shapes. You met Result<T> in chapter 5 — it's a discriminated union parameterised by the success type.
How the compiler walks a union
The narrowing flows through if/else chains, switch statements, early returns, and even ternaries — anywhere the compiler can prove a possibility has been ruled out, the type narrows.
Custom type guards — value is User
Sometimes the runtime check is more complex than typeof or instanceof. Maybe you're validating a parsed JSON object against an interface. A custom type guard is a function whose return type is a special predicate of the form value is User:
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>).email === "string"
);
}
const data: unknown = JSON.parse(rawText);
if (isUser(data)) {
console.log(data.email); // ✅ data narrowed to User
} else {
console.log("not a user");
}The value is User return type tells TypeScript: "if I return true, you can treat the argument as User from the call site forward." The body still has to do the actual runtime check. Custom guards are how libraries like Zod and io-ts hand you typed values out of untrusted input — though both add proper schema validation on top.
A QA-shaped example
Mixing every guard type into one handleApiResult function:
type ApiResult =
| { kind: "success"; data: User }
| { kind: "validation"; errors: string[] }
| { kind: "network"; error: ApiError };
function handleApiResult(result: ApiResult): string {
switch (result.kind) {
case "success":
return `Welcome, ${result.data.name}`;
case "validation":
return `Invalid: ${result.errors.join(", ")}`;
case "network":
// result is narrowed; result.error is ApiError
return `Network ${result.error.statusCode}: ${result.error.message}`;
}
}Three branches, three narrowings, no casts. Every consumer of ApiResult shares the same vocabulary, and adding a fourth case (e.g. "timeout") tells the compiler to walk every existing handler — exhaustiveness checks (next lesson's never trick) make sure no consumer silently misses the new case.
⚠️ Common mistakes
- Forgetting that narrowing is scoped to a branch. Inside
if (typeof x === "string")the value isstring. After theifends, the original union returns — TypeScript doesn't carry the narrowing to surrounding code unless you re-check or assign a new variable. If you need the narrow type later, capture it:const s = x as string;is wrong;const s: string = x;(after a guard inside the same scope) works. - Using
instanceofon plain object literals.instanceofonly narrows class instances.if (response instanceof SuccessResponse)is a compile error ifSuccessResponseis an interface, not a class. Use theinoperator or a discriminated union for object-literal unions. - Writing custom guards that lie.
function isUser(v: unknown): v is User { return true; }compiles but tells TypeScript the value is a User regardless. Custom guards must do real runtime checks — at minimum, verify the discriminating fields exist with the right types. A lying guard is a runtime bug pretending to be type safety.
🎯 Practice task
Build a typed event handler. 25-35 minutes.
- In your
ts-for-qa/srcfolder, createnarrow.ts. - Define
type TestEvent = { type: "started"; testName: string } | { type: "passed"; testName: string; duration: number } | { type: "failed"; testName: string; error: string }. - Write
function logEvent(event: TestEvent): voidusing aswitchonevent.type. Confirm autocomplete shows only the relevant fields inside each case. - Define
class ApiError extends Error { statusCode: number; constructor(...) { ... } }. Writefunction reportError(e: Error | ApiError): stringusinginstanceofto accessstatusCodeonly onApiError. - Write
function handleResult(r: { data: User } | { error: string }): stringusing theinoperator. - Write
function isUser(value: unknown): value is Userthat verifiesid,name, andemailare present with the right primitive types. Use it on a parsed JSON value. - Trigger every narrowing failure. After each, read the error and revert:
- Access
event.durationoutside the"passed"case. - Read
e.statusCodewithout theinstanceof ApiErrorcheck. - Use the result of
JSON.parseas aUserwithout callingisUserfirst.
- Access
- Stretch: combine all four patterns into one
handleApiResult(result: ApiResult)like the lesson example. Walk every branch in VS Code hovering overresultto see the narrowed type at each step.
The next lesson is the partner technique to type guards: assertion functions, which throw on failure and narrow on success — the building block of every typed test-assertion library.