TypeScript Type Narrowing and Type Guards
- typescript
- javascript
- type-guards
- frontend
- backend
Narrowing is the quiet workhorse of everyday TypeScript. Every time you check whether a value is null before using it, or test typeof x === "string", you are narrowing — telling the compiler that within this block, a broad type is actually something more specific. You do it constantly, often without naming it. Understanding how narrowing works, and how to extend it with your own type guards, is what lets you write code that is both safe against bad data and free of the noisy casts that come from fighting the type system.
This is part of the TypeScript type system series. Here we cover the built-in ways TypeScript narrows and how to teach it to narrow your own types.
What narrowing is
A value often starts with a broad type — a union, or something that might be null. Narrowing is the process by which TypeScript uses the control flow of your code to figure out a more specific type within a particular block. The compiler follows your if checks and switch cases and updates what it knows about a variable as it goes.
function printLength(value: string | null) {
// here, value is string | null
if (value === null) return
// here, value is string — the null case returned already
console.log(value.length)
}
Nothing special was needed; the return in the null branch means that past it, only string remains. This is control-flow analysis, and it is happening on every line. The goal of everything below is to give the compiler enough information to narrow correctly, so you can use a value safely without asserting anything.
The built-in narrowing operators
TypeScript understands the ordinary JavaScript checks you already write. Each one narrows in a specific way.
typeof narrows primitives. It is the tool for unions of primitive types:
function format(value: string | number) {
if (typeof value === "string") return value.trim()
return value.toFixed(2) // value is number here
}
instanceof narrows to a class, which is what you want when working with class instances or distinguishing error types:
try { /* ... */ }
catch (err) {
if (err instanceof ValidationError) return err.fields
if (err instanceof Error) return err.message
}
The in operator narrows by checking whether a property exists, useful for distinguishing object shapes that do not share a discriminant:
function area(shape: Circle | Square) {
if ("radius" in shape) return Math.PI * shape.radius ** 2
return shape.side ** 2
}
Truthiness narrows away null, undefined, 0, and "". A plain if (value) removes the nullish cases, which is the most common narrowing of all. Be a little careful with it, though: if (count) also excludes 0, which may be a valid value you did not mean to skip.
Equality checks narrow too, including against literal types — which is exactly the mechanism that makes discriminated unions work when you switch on their discriminant.
User-defined type guards
The built-in operators cover primitives and classes, but they cannot narrow arbitrary object shapes on their own. For that, you write a type guard: a function that returns a boolean and whose return type is a special value is Type annotation, called a type predicate. When the function returns true, TypeScript narrows the argument to that type.
interface User {
id: string
name: string
}
function isUser(value: unknown): value is User {
return (
typeof value === "object" &&
value !== null &&
"id" in value &&
"name" in value
)
}
const data: unknown = await response.json()
if (isUser(data)) {
console.log(data.name) // data is User inside this block
}
The value is User return type is the important part. To TypeScript, isUser is not just a function returning a boolean; it is a promise that a true result means the argument really is a User, and the compiler narrows accordingly. This is the clean way to validate data crossing your program's boundary — an API response, a parsed JSON payload, a message from another system — turning an unknown into a known type through a check you control rather than an as cast you are asserting on faith.
A caution comes with the power: TypeScript trusts your predicate. If isUser returns true for something that is not actually a User, the compiler believes you and you get a runtime bug the types said could not happen. The narrowing is only as sound as the check inside, so make the check genuinely verify the shape. For anything beyond simple guards, a schema library like Zod generates the type guard and the type together from one schema, which removes the risk of the check and the type drifting apart.
Assertion functions
A close relative is the assertion function, which throws instead of returning a boolean. Its annotation is asserts value is Type, and after it is called, the value is narrowed for the rest of the scope:
function assertIsUser(value: unknown): asserts value is User {
if (!isUser(value)) throw new Error("Not a User")
}
assertIsUser(data)
// data is User from here on — no if block needed
This suits the "validate or fail" style, where invalid data is a genuine error rather than a case to branch on. It reads naturally at the top of a function that requires its input to have a certain shape: assert it, and proceed as if it does, because if it did not, execution never got this far.
Why this matters
Narrowing is what lets TypeScript's type system meet the messy reality at the edges of your program, where data arrives as unknown or any and could be anything. The alternative to narrowing is casting — telling the compiler "trust me, it's a User" with as — and casting is a lie you might be wrong about. Narrowing, done through real runtime checks and honest type guards, gives you the same specific types with an actual verification behind them. Master it and you rarely need to cast, which means the type system keeps protecting you all the way to the boundary instead of going quiet exactly where the risk is highest.
Key takeaways
- Narrowing uses control flow — your if checks and returns — to refine a broad type to a more specific one within a block.
- Built-in operators narrow different things: typeof for primitives, instanceof for classes, in for property presence, and truthiness for nullish values.
- A user-defined type guard returns a "value is Type" predicate so the compiler narrows arbitrary object shapes after a check you control.
- Assertion functions with "asserts value is Type" throw on invalid input and narrow for the rest of the scope — the validate-or-fail style.
- Narrowing through real runtime checks beats casting with as, which is an unverified assertion that can be wrong at runtime.
Frequently asked questions
What is type narrowing in TypeScript?
The process by which TypeScript uses your code's control flow — if checks, switch cases, early returns — to refine a broad type into a more specific one within a particular block. After if (value === null) return, the compiler knows value is non-null below that line.
What is a user-defined type guard?
A function returning a boolean whose return type is a type predicate like value is User. When it returns true, TypeScript narrows the argument to that type. It is the clean way to turn an unknown value from an API or JSON into a known type through a check you control.
What is the difference between a type guard and an assertion function?
A type guard returns a boolean predicate (value is Type) and you branch on it with an if. An assertion function uses asserts value is Type and throws when the check fails, narrowing the value for the rest of the scope without an if block — suited to validate-or-fail code.
Is narrowing safer than using "as" casts?
Yes. A cast with as tells the compiler to trust you with no runtime verification, so a wrong cast is a bug the types said was impossible. Narrowing through real checks and honest type guards gives the same specific type with an actual verification behind it.
Further reading
Explore more on

About the author
I'm Aman Kumar Singh, a software engineer in Noida, India building scalable full-stack products with React, Next.js, Node.js, NestJS, PostgreSQL, Redis, and AWS. I write about backend engineering, distributed systems, and system design.