cheat sheet
Type Narrowing
TypeScript narrowing refines broad types to specific ones within code branches. Covers typeof, instanceof, in, equality, assignment narrowing, discriminated unions, control flow analysis, and the never type.
Type Narrowing
What it is
Narrowing is TypeScript's ability to refine a broad type to a more specific one within a code branch, based on runtime checks. When TypeScript sees a conditional check, it uses control flow analysis to update the type of a variable on each branch. This allows you to safely call methods or access properties that only exist on specific types — without casting.
typeof narrowing
The typeof operator narrows to one of the eight primitive type strings:
function processValue(val: string | number | boolean | undefined) {
if (typeof val === "string") {
console.log(val.toUpperCase()); // val: string
} else if (typeof val === "number") {
console.log(val.toFixed(2)); // val: number
} else if (typeof val === "boolean") {
console.log(val ? "yes" : "no"); // val: boolean
} else {
console.log("undefined"); // val: undefined
}
}
All typeof narrow targets:
typeof x === "string"
typeof x === "number"
typeof x === "boolean"
typeof x === "object" // narrows to object | null (typeof null === "object")
typeof x === "function"
typeof x === "symbol"
typeof x === "bigint"
typeof x === "undefined"
typeof null === "object"is a longstanding JavaScript quirk. Aftertypeof x === "object",xis stillnull | SomeObject. Follow up with a null check.
function handleObject(val: string | null | { id: number }) {
if (typeof val === "object") {
// val: null | { id: number }
if (val !== null) {
console.log(val.id); // val: { id: number }
}
}
}
Truthiness narrowing
Falsy values (false, 0, "", null, undefined, NaN, 0n) narrow away the falsy members:
function printName(name: string | null | undefined) {
if (name) {
console.log(name.toUpperCase()); // name: string (non-empty)
} else {
console.log("(no name)"); // name: string | null | undefined (includes "")
}
}
Truthiness narrowing removes
nullandundefinedbut also removes the empty string"",0, and other falsy values. If an empty string is valid, use an explicit!= nullcheck instead of a bare truthiness check.
function processInput(val: string | null) {
if (val != null) { // strict null check — "" is kept
console.log(val.length); // val: string (includes "")
}
}
instanceof narrowing
instanceof narrows to a specific class:
class ApiError extends Error {
constructor(public statusCode: number, message: string) {
super(message);
this.name = "ApiError";
}
}
class NetworkError extends Error {
constructor(public retryable: boolean, message: string) {
super(message);
}
}
function handleError(err: ApiError | NetworkError | Error) {
if (err instanceof ApiError) {
console.log(`API error ${err.statusCode}: ${err.message}`);
} else if (err instanceof NetworkError) {
if (err.retryable) console.log("Retrying...");
} else {
console.log(`Unexpected error: ${err.message}`);
}
}
in operator narrowing
The in operator checks if a property exists on an object, narrowing the union:
interface Cat { meow(): void; purr(): void }
interface Dog { bark(): void; fetch(): void }
function makeSound(animal: Cat | Dog) {
if ("meow" in animal) {
animal.meow(); // animal: Cat
} else {
animal.bark(); // animal: Dog
}
}
Also useful for optional vs required property distinction:
interface WithId { id: string; name: string }
interface WithSlug { slug: string; name: string }
function getRoute(resource: WithId | WithSlug): string {
if ("id" in resource) {
return `/items/${resource.id}`;
}
return `/items/${resource.slug}`;
}
Equality narrowing
Strict equality (=== / !==) and loose equality (== / !=) both narrow:
function processStatus(code: string | number) {
if (code === 200) {
// code: 200 (literal number type)
console.log("OK");
} else if (code === "error") {
// code: "error" (literal string type)
console.log("Error");
} else {
// code: string | number (everything else)
}
}
Loose equality with null is especially useful — == null catches both null and undefined:
function coalesce(val: string | null | undefined, fallback: string): string {
if (val == null) return fallback; // removes null and undefined
return val; // val: string
}
Assignment narrowing
TypeScript narrows based on the value assigned to a variable:
let result: string | number;
result = "hello";
console.log(result.toUpperCase()); // result: string — narrowed by assignment
result = 42;
console.log(result.toFixed(1)); // result: number — narrowed by new assignment
Control flow analysis
TypeScript tracks types through the entire control flow of a function, including early returns, throws, and assignments:
function processUser(user: User | null) {
if (user === null) {
throw new Error("No user");
}
// user: User — TypeScript knows null was thrown out
if (!user.active) {
return; // early return
}
// user: User & { active: true } — implicitly narrowed after the check
console.log(user.name);
}
Multiple assignments and branching:
let text: string | undefined;
text = maybeGetText(); // string | undefined
if (text !== undefined) {
const upper = text.toUpperCase(); // text: string
}
// After the block — text is still string | undefined
// TypeScript does not narrow based on what happened inside blocks
Discriminated unions (tagged unions)
A discriminated union is a union of object types that each have a common literal field (the discriminant). TypeScript uses the discriminant to narrow the union:
type Shape =
| { kind: "circle"; radius: number }
| { kind: "square"; side: number }
| { kind: "rectangle"; width: number; height: number };
function area(shape: Shape): number {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2; // shape: { kind: "circle"; radius: number }
case "square":
return shape.side ** 2; // shape: { kind: "square"; side: number }
case "rectangle":
return shape.width * shape.height; // shape: { kind: "rectangle"; ... }
}
}
Discriminated unions work best with:
- A literal string (
kind,type,tag,status) as the discriminant - A
switchstatement or if/else chain on the discriminant - A
neverdefault branch for exhaustiveness checking
never and exhaustiveness checking
After narrowing, if a branch is unreachable, TypeScript infers the type as never. You can use this to write an exhaustive check that fails at compile time if a new union member is added but not handled:
function assertNever(value: never): never {
throw new Error(`Unhandled value: ${JSON.stringify(value)}`);
}
function describeShape(shape: Shape): string {
switch (shape.kind) {
case "circle": return `Circle r=${shape.radius}`;
case "square": return `Square s=${shape.side}`;
case "rectangle": return `Rect ${shape.width}x${shape.height}`;
default:
return assertNever(shape); // Error if Shape union grows and case is missing
}
}
If you add | { kind: "triangle"; base: number; height: number } to Shape and forget to add a case "triangle", TypeScript will report an error on the assertNever(shape) line because shape is no longer never.
Narrowing with assignment after use
let id: string | number = getId();
if (typeof id === "string") {
id = parseInt(id, 10); // id: number after assignment
}
console.log(id.toFixed(0)); // id: number — control flow knows it was assigned
Summary of narrowing techniques
| Technique | Example | Narrows via |
|---|---|---|
typeof | typeof x === "string" | Primitive type check |
| Truthiness | if (x) | Falsy/truthy branch |
instanceof | x instanceof Date | Prototype chain |
in operator | "id" in obj | Property existence |
| Equality | x === "active" | Literal match |
| Assignment | x = "hello" | Assigned value type |
| Discriminant | switch (x.kind) | Union discriminant literal |
| Type guard function | isString(x) | is predicate return |
| Assertion function | assertDefined(x) | asserts return |
Control flow analysis — the deep dive
Control flow analysis is the engine that makes narrowing possible. The TypeScript compiler walks every path through your code — straight-line, branches, loops, early returns, throws, and assignments — and tracks the current narrowed type of each variable at each point. The narrowed type is the intersection of every constraint the path has imposed so far, minus everything any earlier branch has thrown out.
The mental model: imagine a small bookkeeper sitting inside the compiler that updates a "current type" sticker on each variable whenever your code does anything observable to that variable — a check, a cast, an assignment, a throw, a function call that asserts. The sticker is what hover-tooltips display, and what method calls are checked against.
function example(input: string | number | null) {
// input: string | number | null
if (input === null) {
return;
}
// input: string | number — null branch returned
if (typeof input === "string") {
// input: string
console.log(input.toUpperCase());
return;
}
// input: number — only branch left
console.log(input.toFixed(2));
}
Every early return, throw, or process.exit() call subtracts the current narrowing from the rest of the function, which is why the pattern of "guard at the top, work below" composes so well with TypeScript.
Narrowing through loops
Narrowing inside a loop body persists for that iteration but resets at the top of the next iteration, because the variable could have been reassigned. If you want narrowing across iterations, store the narrowed value in a fresh const.
function process(items: Array<string | null>) {
for (const item of items) {
// item: string | null at the start of each iteration
if (item === null) continue;
// item: string for the rest of this iteration
console.log(item.toUpperCase());
}
}
Narrowing across function boundaries
TypeScript narrows within a function but loses narrowing the moment you call another function — the compiler has no way to know whether the callee mutated a captured variable. This is the single largest source of "I narrowed it, why is it still nullable?" complaints.
function example(state: { value: string | null }) {
if (state.value === null) return;
// state.value: string
doWork(); // any side effect
// state.value: string | null again — compiler reset the narrowing
// because doWork() could have set state.value = null
console.log(state.value.toUpperCase()); // Error TS2531
}
The fix:
function example(state: { value: string | null }) {
if (state.value === null) return;
const v = state.value; // capture into a const
doWork();
console.log(v.toUpperCase()); // OK — v is string, not state.value
}
A local const is invisible to outside mutation. This is the standard pattern for narrowing across await boundaries, callbacks, and any non-trivial control flow.
Narrowing and async/await
Awaiting a Promise is a function-call boundary — TypeScript will discard narrowing of mutable fields across it.
async function example(user: { name: string | null }) {
if (user.name === null) return;
// user.name: string
await delay(100);
// user.name: string | null — narrowing lost across await
console.log(user.name.length); // Error
}
Local const capture is the fix here too:
async function example(user: { name: string | null }) {
if (user.name === null) return;
const name = user.name;
await delay(100);
console.log(name.length); // OK
}
typeof — full reference
typeof narrows to one of eight primitive type tag strings. The list and what each narrows to:
typeof x === ... | Result type |
|---|---|
"string" | string |
"number" | number |
"boolean" | boolean |
"undefined" | undefined |
"object" | object | null (note the null!) |
"function" | Function |
"symbol" | symbol |
"bigint" | bigint |
The null quirk is the famous JavaScript bug from 1995 that was preserved for backwards compatibility. typeof null === "object", so after typeof x === "object" your value is still possibly null.
function example(val: unknown) {
if (typeof val === "object") {
// val: object | null
if (val !== null) {
// val: object
console.log(Object.keys(val));
}
}
}
typeof does not narrow to specific class instances — for Date, RegExp, Map, Set, etc., use instanceof instead.
instanceof — full reference
instanceof checks the prototype chain at runtime, so it narrows to the specific class and any subclasses.
abstract class Vehicle {
abstract wheels(): number;
}
class Car extends Vehicle {
wheels() { return 4 }
honk() { console.log("beep!") }
}
class Motorcycle extends Vehicle {
wheels() { return 2 }
wheelie() { console.log("yeehaw!") }
}
function example(v: Vehicle) {
if (v instanceof Car) {
v.honk(); // v: Car
} else if (v instanceof Motorcycle) {
v.wheelie(); // v: Motorcycle
} else {
// v: Vehicle — could still be a future subclass we don't know about
}
}
instanceof has three notable gotchas:
- Cross-realm failures — an object created in one window/iframe/worker fails
instanceofagainst the same-named class in another realm because the prototypes are different references. This breaksinstanceof Arrayfor cross-iframe arrays; useArray.isArray()which uses a tag and works across realms. - Plain objects after JSON round-trip —
JSON.parse(JSON.stringify(date))returns a string, not aDate. The prototype is lost across serialization. - Custom errors lose their class identity in older transpilers —
class MyError extends Errorworked incorrectly in TypeScript targeting ES5 because of howError's constructor handles prototype chains. Fixed in ES2015+ targets.
class MyError extends Error {
constructor(message: string, public code: number) {
super(message);
Object.setPrototypeOf(this, MyError.prototype); // ES5 target workaround
}
}
in operator — full reference
The in operator narrows based on whether a property exists on an object. It is the workhorse for narrowing union types where the variants do not share a common discriminant property.
type ApiResponse =
| { data: User[]; meta: { total: number } }
| { error: string; code: number };
function handle(res: ApiResponse): void {
if ("data" in res) {
console.log(`got ${res.data.length} users`);
} else {
console.error(`[${res.code}] ${res.error}`);
}
}
Limitations:
- Inherited properties count —
"toString" in {}istruebecause every object inherits fromObject.prototype. UseObject.hasOwn(obj, "key")if you need own-property semantics, though that does not narrow types. - Optional properties —
"data" in resistruewhen the property exists with any value, includingundefined. Ifdata?: User[]is optional and undefined, theincheck still passes. - Symbols and computed keys —
inworks with symbol keys too, but you cannot pass a generic string variable as the left side without narrowing it first.
function hasProp<K extends string>(obj: object, key: K): obj is Record<K, unknown> {
return key in obj;
}
Equality narrowing — strict vs loose
Strict equality (===, !==) narrows to the literal value on either side. Loose equality (==, !=) does the same plus the JavaScript coercion rules.
type Status = "pending" | "active" | "done";
function isFinal(s: Status): boolean {
return s === "done"; // narrows s to "done" inside the comparison
}
function example(val: string | null | undefined) {
if (val == null) {
// val: null | undefined — loose equality catches both
return;
}
// val: string
console.log(val.toUpperCase());
}
Loose == null is the canonical "is this nullish" check. The TypeScript team and the official ESLint config explicitly carve it out as an exception to the "never use ==" rule — see @typescript-eslint/eqeqeq with allowNull: true.
Equality narrowing on a union
When you compare two unions of literals, TypeScript narrows both sides to the intersection of their literal values.
function example(a: "x" | "y" | "z", b: "x" | "y") {
if (a === b) {
// a: "x" | "y" (intersection of "x"|"y"|"z" and "x"|"y")
// b: "x" | "y" (same)
}
}
This is occasionally useful in switch-like code where two variables are correlated. Most of the time it is just a fun property of the algorithm to know.
Truthiness — the empty-string trap
Truthiness narrowing removes every falsy value: 0, "", false, null, undefined, NaN, 0n, 0.0. That is more aggressive than most developers expect — a bare if (str) rejects the empty string, which is valid data in many domains (search queries, form fields, etc.).
function search(query: string | undefined) {
if (!query) {
// query: "" | undefined — empty string was lumped in with undefined!
return [];
}
return runSearch(query);
}
// The empty-string case is now indistinguishable from "no query passed"
search(""); // returns [] — but maybe user wants all results?
search(undefined); // returns []
The fix is to be explicit about which falsy values you mean to filter:
function search(query: string | undefined) {
if (query === undefined) {
return getAllResults();
}
return runSearch(query); // "" runs an empty search, as the caller intended
}
A common house rule: == null for "is this nullish", and explicit === "" or === 0 for those values. Never use bare truthiness for narrowing string/number unions unless you mean the falsy set.
Discriminated unions — cross-link
A discriminated (tagged) union is a union of object types that all share a literal-typed property. Switching on that property narrows the union to one variant. This is the most powerful and most-used narrowing technique in real-world TypeScript — it underpins Redux actions, Result<T, E> types, finite state machines, WebSocket protocols, and React reducer hooks.
See the dedicated discriminated unions article for the full deep dive. The narrowing-specific summary:
type AsyncState<T> =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: T }
| { status: "error"; message: string };
function render<T>(state: AsyncState<T>, fmt: (data: T) => string): string {
switch (state.status) {
case "idle": return "(idle)";
case "loading": return "loading…";
case "success": return fmt(state.data);
case "error": return `error: ${state.message}`;
}
}
The literal status property is what makes narrowing exhaustive — every variant has a unique tag, so the compiler can prove which variant you are inside each case.
Assertion functions — asserts
An assertion function is a function whose return type is asserts <param> [is T]. When the function returns normally (does not throw), TypeScript narrows the parameter for the rest of the calling scope. This is more powerful than a boolean-returning type guard because the narrowing applies to the line after the call rather than inside an if.
function assert(condition: unknown, message: string): asserts condition {
if (!condition) throw new Error(message);
}
function example(input: string | null) {
assert(input !== null, "input must not be null");
// input: string — narrowing persists past the call site
console.log(input.toUpperCase());
}
function assertIsString(val: unknown): asserts val is string {
if (typeof val !== "string") {
throw new TypeError(`expected string, got ${typeof val}`);
}
}
const raw: unknown = JSON.parse('"hello"');
assertIsString(raw);
console.log(raw.toUpperCase()); // raw: string
Two forms of asserts:
asserts condition— narrows nothing specific; signals "if this returns, the boolean expression was truthy". Useful for general invariants.asserts val is T— narrowsvaltoT. Equivalent power to a type guard but applies as a statement rather than a condition.
The runtime contract: an assertion function must throw when its condition is false. There is no way for the compiler to verify this; you are signing a contract with TypeScript.
Exhaustiveness checking with never
never is TypeScript's "uninhabited" type — no value has type never. When you narrow a union and every member is excluded, the remaining type is never. You can use this to write a check that fails at compile time if a union grows and a switch does not.
function assertNever(value: never): never {
throw new Error(`Unhandled value: ${JSON.stringify(value)}`);
}
type Shape =
| { kind: "circle"; radius: number }
| { kind: "square"; side: number };
function describe(shape: Shape): string {
switch (shape.kind) {
case "circle": return `r=${shape.radius}`;
case "square": return `s=${shape.side}`;
default: return assertNever(shape);
}
}
// Add | { kind: "triangle"; base: number; height: number } to Shape and
// the default branch fails to compile because `shape` is no longer `never`.
never also appears in:
- Intersection of disjoint types —
string & numbercollapses tonever. - Function return type when the function always throws —
function fail(): never { throw new Error() }. Promise<never>— a promise that never resolves (used in some rejection helpers).
Narrowing limitations
These are the patterns where narrowing silently fails — knowing them saves hours of "why doesn't this work" debugging.
1. Mutable property access narrowing is fragile
type Box = { value: string | null };
function example(box: Box) {
if (box.value !== null) {
// box.value: string
box = { value: null };
console.log(box.value.toUpperCase()); // Error — box was reassigned
}
}
A property of an object is narrowed only as long as the object reference does not change. Reassigning box resets every narrowed property on it.
2. Property narrowing on aliased values
type Box = { value: string | null };
function example(box: Box) {
if (box.value !== null) {
const v = box.value;
// v: string
randomFn(); // unknown side effects
console.log(box.value.toUpperCase()); // Error TS2531
console.log(v.toUpperCase()); // OK — v captured before the call
}
}
Captures of narrowed values into a local const are immune to invalidation.
3. Generic parameter narrowing is partial
Narrowing a generic parameter narrows the call-site but not the type variable itself. This sometimes confuses developers writing higher-order functions.
function example<T>(val: T | null) {
if (val === null) return;
// val: T — narrowed to "not null", but the type variable T still includes null possibilities
return val; // returns T, not NonNullable<T>
}
If you need a non-null version of the type, use NonNullable<T> explicitly:
function example<T>(val: T | null): NonNullable<T> {
if (val === null) throw new Error();
return val as NonNullable<T>;
}
4. Method calls on union types
If two variants of a union share a method name but with incompatible signatures, calling that method on the un-narrowed union produces never or a confusing error.
type Container =
| { kind: "list"; push(item: string): void }
| { kind: "set"; push(item: number): void };
function example(c: Container) {
c.push("hello"); // Error — push expects `string & number` = `never`
}
// Fix: narrow first
function fixed(c: Container) {
if (c.kind === "list") c.push("hello");
}
5. delete does not narrow
type Box = { value?: string };
function example(box: Box) {
if (box.value !== undefined) {
delete box.value;
// box.value: string | undefined — delete does not refine the type
}
}
6. Default values in destructuring do not narrow the source
function example({ name = "anonymous" }: { name?: string }) {
// name: string (because of default) — useful!
// but the parameter itself is still { name?: string }
}
This one is actually favourable — the destructured local is narrower than the parameter shape.
Narrowing with branded types
See the branded types article for the full pattern. The narrowing-specific point: a brand is a phantom property the compiler tracks, so an unknown narrowed via a Zod schema gets a branded type that distinguishes it from raw input downstream.
import { z } from "zod";
const Email = z.string().email().brand<"Email">();
type Email = z.infer<typeof Email>;
function send(to: Email) { console.log(`sending to ${to}`) }
const raw: unknown = "alice@example.com";
const parsed = Email.parse(raw); // parsed: Email — narrowed AND branded
send(parsed);
Common pitfalls
- Narrowing lost across function calls — any function call invalidates narrowed object properties. Capture into a local
constbefore the call. - Truthiness eating empty strings —
if (s)removes"". Use== nullor explicits !== undefinedchecks. typeof x === "object"includes null — always pair with a separate null check.instanceoffailing across realms — useArray.isArray(),ArrayBuffer.isView(), or aSymbol.toStringTag-based check for cross-frame code.- Discriminant not a literal — if
kind: stringinstead ofkind: "circle" | "square", narrowing silently fails. Useas const, an enum, or explicit literal annotations. - Forgetting
assertNever— aswitchwithout an exhaustiveness check silently skips new variants. Always add a default that callsassertNever. - Awaiting between narrowing and use —
awaitis a function-call boundary. Capture narrowed values to a localconstfirst. - Optional discriminant —
{ kind?: "a" }does not narrow because the property may be absent. Make discriminants required. deletenot narrowing —delete obj.foodoes not change the type ofobj.foo. Reassign or restructure if you need the narrowing.- Mutating a narrowed value — reassigning the narrowed variable resets its type to the original. Use a
constcapture or accept the reset.
Real-world recipes
Recipe 1 — typed fetch with full error narrowing
A fetch wrapper that returns { ok: true; data: T } | { ok: false; error: Error }. The caller narrows once and gets full type safety down each branch.
async function safeFetch<T>(url: string): Promise<{ ok: true; data: T } | { ok: false; error: Error }> {
try {
const res = await fetch(url);
if (!res.ok) {
return { ok: false, error: new Error(`HTTP ${res.status}`) };
}
const data = (await res.json()) as T;
return { ok: true, data };
} catch (err) {
return { ok: false, error: err instanceof Error ? err : new Error(String(err)) };
}
}
interface User { id: string; name: string }
const result = await safeFetch<User>("https://api.example.com/users/1");
if (result.ok) {
console.log(`user: ${result.data.name}`);
} else {
console.error(`failed: ${result.error.message}`);
}
Recipe 2 — narrowing DOM queries
document.querySelector returns Element | null. A common pattern is to narrow with instanceof to the specific subtype before using it.
function focusInput(selector: string): void {
const el = document.querySelector(selector);
if (el instanceof HTMLInputElement) {
el.focus();
el.select();
} else if (el === null) {
console.warn(`no element matches ${selector}`);
} else {
console.warn(`${selector} is not an input`);
}
}
Recipe 3 — Node.js error narrowing
Node's filesystem and child-process errors put the error code on a non-standard code property. Narrow with a type guard before reading it.
import { readFile } from "node:fs/promises";
interface NodeError extends Error {
code: string;
syscall?: string;
path?: string;
}
function isNodeError(err: unknown): err is NodeError {
return err instanceof Error && typeof (err as { code?: unknown }).code === "string";
}
async function readConfig(path: string): Promise<string | null> {
try {
return await readFile(path, "utf8");
} catch (err) {
if (isNodeError(err) && err.code === "ENOENT") {
return null; // file doesn't exist — treat as missing config
}
throw err; // unexpected error — re-throw
}
}
Recipe 4 — exhaustive event router
A WebSocket router that handles a typed event union. Adding a new event type causes a compile error in the router until it is handled.
type Event =
| { type: "join"; userId: string }
| { type: "leave"; userId: string }
| { type: "message"; userId: string; text: string }
| { type: "typing"; userId: string };
function assertNever(x: never): never {
throw new Error(`Unhandled event: ${JSON.stringify(x)}`);
}
function route(event: Event): void {
switch (event.type) {
case "join": console.log(`${event.userId} joined`); break;
case "leave": console.log(`${event.userId} left`); break;
case "message": console.log(`${event.userId}: ${event.text}`); break;
case "typing": console.log(`${event.userId} is typing`); break;
default: assertNever(event);
}
}
Recipe 5 — config validation with assertion functions
Parsing environment variables with assertion functions keeps the validation site centralized and gives downstream code narrow types.
function assertString(val: unknown, name: string): asserts val is string {
if (typeof val !== "string" || val.length === 0) {
throw new Error(`Expected ${name} to be a non-empty string`);
}
}
function assertNumber(val: unknown, name: string): asserts val is number {
if (typeof val !== "number" || Number.isNaN(val)) {
throw new Error(`Expected ${name} to be a finite number`);
}
}
function loadConfig(): { host: string; port: number; apiKey: string } {
const host = process.env.HOST;
const port = Number(process.env.PORT);
const apiKey = process.env.API_KEY;
assertString(host, "HOST");
assertNumber(port, "PORT");
assertString(apiKey, "API_KEY");
// host: string, port: number, apiKey: string — all narrowed
return { host, port, apiKey };
}
Recipe 6 — narrowing async iterators
Async iterators emit values whose type might be a union. Narrow inside the loop to dispatch to per-variant handlers.
type LogEntry =
| { level: "info"; message: string }
| { level: "error"; message: string; stack?: string };
async function* readLogs(): AsyncIterableIterator<LogEntry> {
yield { level: "info", message: "starting up" };
yield { level: "error", message: "boom", stack: "..." };
}
async function process(): Promise<void> {
for await (const entry of readLogs()) {
if (entry.level === "error") {
console.error(`[ERROR] ${entry.message}`);
if (entry.stack) console.error(entry.stack);
} else {
console.log(`[INFO] ${entry.message}`);
}
}
}