Overview
Narrowing is how a wide type becomes a usable type. The compiler narrows on type guards, discriminants, and assertion functions; everything else is a cast. Read typescript-strict-mode first; narrowing only matters because strict mode makes wide types unsafe to use.
Narrow primitives with typeof
typeof is the cheapest guard. Use it for the seven typeof results: "string", "number", "bigint", "boolean", "symbol", "undefined", "object", "function".
function format(value: string | number | null): string {
if (typeof value === "string") return value
if (typeof value === "number") return value.toFixed(2)
return "n/a"
}Note that typeof null is "object". To rule out null, compare to null directly or check value != null.
Narrow class instances with instanceof
instanceof narrows to the constructor’s instance type. Use it after a catch (with useUnknownInCatchVariables on, the variable is unknown).
try {
await loadInvoice(id)
} catch (err) {
if (err instanceof HttpError) {
return { status: err.status }
}
if (err instanceof Error) {
logger.error({ message: err.message })
return { status: 500 }
}
throw err
}instanceof does not work across realm boundaries (iframes, vm contexts) and does not work on plain object literals. Use a discriminant for those cases.
Narrow object shapes with in
in narrows when one branch has a property the other does not. It is the right tool for two object shapes that lack a discriminant.
type Cat = { meow(): void }
type Dog = { bark(): void }
function speak(animal: Cat | Dog) {
if ("meow" in animal) {
animal.meow()
} else {
animal.bark()
}
}For three or more shapes, add an explicit discriminant field instead; in checks compound poorly.
Prefer discriminated unions over in and instanceof
A discriminated union has a literal-typed kind (or type, status) field. The compiler narrows on equality with the discriminant.
type Invoice =
| { status: "draft"; id: string }
| { status: "sent"; id: string; sentAt: Date }
| { status: "paid"; id: string; sentAt: Date; paidAt: Date }
function display(invoice: Invoice): string {
switch (invoice.status) {
case "draft": return `Draft ${invoice.id}`
case "sent": return `Sent at ${invoice.sentAt.toISOString()}`
case "paid": return `Paid at ${invoice.paidAt.toISOString()}`
}
}The compiler narrows invoice inside each case to exactly the variant with that status. This is the most useful narrowing pattern in TypeScript. See typescript for why narrow types beat flat-with-optionals.
Exhaustiveness-check switches with never
Add a default branch that assigns to never. The compiler errors if a new union member is added and the switch is not updated.
function display(invoice: Invoice): string {
switch (invoice.status) {
case "draft": return `Draft`
case "sent": return `Sent`
case "paid": return `Paid`
default: {
const _exhaustive: never = invoice
throw new Error(`Unhandled status: ${JSON.stringify(_exhaustive)}`)
}
}
}When a fourth status arrives, the assignment to never fails to compile and points you at every switch that needs an update. This converts a class of runtime bugs into compile errors.
Write user-defined type predicates sparingly
A function that returns value is T is a custom narrowing guard. Use it when the built-in guards do not express the check.
function isInvoice(value: unknown): value is Invoice {
return (
typeof value === "object" &&
value !== null &&
"status" in value &&
typeof (value as { status: unknown }).status === "string"
)
}
if (isInvoice(data)) {
// data is Invoice here
}The compiler trusts the predicate; if the body lies, you get a runtime bug. For untrusted data (HTTP, FS, env), use a schema parser instead of a predicate. See typescript-runtime-validation.
Use assertion functions for invariants
An assertion function throws on failure and narrows on success. Signature: function assertX(value): asserts value is X.
function assertDefined<T>(value: T | undefined, message: string): asserts value is T {
if (value === undefined) throw new Error(message)
}
const row = rows[0]
assertDefined(row, "expected at least one row")
// row is T here, not T | undefinedUse these at boundaries between “I know this is set” and the type system disagreeing. Pair with noUncheckedIndexedAccess to keep the T | undefined honest. See typescript-strict-mode for the flag rationale.
Narrow with equality, then act
The narrowing flow is: check, then use inside the narrowed scope. Do not re-check; the compiler already knows.
function trim(value: string | null): string {
if (value === null) return ""
return value.trim() // value is string here
}Re-checking (if (value !== null) return value.trim()) makes the reader prove the narrowing twice. Pick the early return; let the rest of the function read the narrowed type. See typescript-utility-types for NonNullable<T> and the related helpers.