Type Narrowing & Type Guards
Reviewed & published by Brayan K
Type narrowing is how TypeScript refines a broad type into a more specific one inside a block — using runtime checks like typeof, instanceof, and custom type guards — so you can use the value safely.
Part of the free TypeScript course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.
What You'll Learn
- Narrow primitive unions with typeof checks
- Use truthiness checks to rule out null, undefined, and empty values
- Narrow class instances with instanceof
- Tell object shapes apart with the 'in' operator
- Write custom type guards with the 'x is Foo' return type
- Branch cleanly on a discriminant field shared across a union
💡 Real-World Analogy
A union type is like a parcel labelled "fragile or heavy". You can't decide how to lift it until you check which it is. Narrowing is that check at the door: once you confirm "this one's fragile", you handle it with care for the rest of the journey. TypeScript watches your checks and updates what it knows about the parcel inside each branch.
1. typeof & Truthiness Narrowing
The simplest narrowing tool is typeof, which distinguishes the primitive types ("string", "number", "boolean", …). Inside if (typeof x === "number"), TypeScript knows x is a number. A bare truthiness check like if (name) is just as useful: it removes null, undefined, and "" from the type, so the value inside the block is guaranteed present.
// TypeScript automatically narrows inside each branch:
function double(x: number | string): number | string | null {
if (typeof x === "number") return x * 2; // x: number here
if (typeof x === "string") return x + x; // x: string here
return null;
}
// Truthiness narrowing removes null/undefined from a union:
function greet(name: string | null | undefined): string {
if (name) return "Hi, " + name; // name: string inside the if
return "Hi, stranger";
}// NARROWING = convincing TypeScript that a broad value is something more
// specific INSIDE a block, so you can use it safely.
// The everyday tools are: typeof, truthiness checks, instanceof, and 'in'.
// --- typeof narrowing (for primitives) ---
function double(x) {
if (typeof x === "number") {
return x * 2; // x is a number here
}
if (typeof x === "string") {
return x + x; // x is a string here (repeat it)
}
return null;
}
console.log(double(21)); // 42
console.log(double("ab")); // abab
console.log(double(true)); // null
// --- truthiness narrowing (rules out null / undefined / "") ---
function greet(name) {
if (name) { // skips null, undefined, and ""
return "Hi, " + name;
}
return "Hi, stranger";
}
console.log(greet("Sam")); // Hi, Sam
console.log(greet("")); // Hi, stranger
console.log(greet(null)); // Hi, stranger
// In a .ts file the parameter might be typed: x: number | string | boolean2. instanceof & in Narrowing
For class instances, instanceof narrows to the class: inside if (animal instanceof Cat) you can call Cat-only methods. For plain object shapes that don't share a class, the in operator checks for a distinguishing property — "radius" in shape tells a circle from a rectangle and narrows the union accordingly.
// instanceof narrows class instances:
function describe(animal: Dog | Cat): string {
if (animal instanceof Cat) return animal.purr(); // animal: Cat
return animal.speak(); // animal: Dog
}
// 'in' narrows object shapes by a distinguishing property:
type Circle = { radius: number };
type Rect = { width: number; height: number };
function area(shape: Circle | Rect): number {
if ("radius" in shape) return Math.PI * shape.radius ** 2; // shape: Circle
return shape.width * shape.height; // shape: Rect
}// --- instanceof narrowing (for class instances) ---
class Dog { speak() { return "Woof"; } }
class Cat { speak() { return "Meow"; } purr() { return "prrr"; } }
function describe(animal) {
if (animal instanceof Dog) {
return "Dog says " + animal.speak(); // animal: Dog here
}
if (animal instanceof Cat) {
return "Cat says " + animal.speak() + " (" + animal.purr() + ")"; // animal: Cat
}
return "unknown";
}
console.log(describe(new Dog())); // Dog says Woof
console.log(describe(new Cat())); // Cat says Meow (prrr)
// --- 'in' narrowing (does this property exist on the object?) ---
function area(shape) {
if ("radius" in shape) {
return Math.PI * shape.radius * shape.radius; // shape has radius -> circle
}
return shape.width * shape.height; // otherwise -> rectangle
}
console.log(area({ radius: 2 }).toFixed(2)); // 12.57
console.log(area({ width: 3, height: 4 })); // 12
// In a .ts file:
// function describe(animal: Dog | Cat): string { ... }
// function area(shape: { radius: number } | { width: number; height: number }) { ... }3. Custom Type Guards & Discriminants
When the built-in checks aren't enough, write your own type guard: a function whose return type is the predicate arg is SomeType. When it returns true, TypeScript narrows the argument to that type wherever you called it. The most ergonomic pattern of all is a discriminant check — switching on a shared literal field (kind) so each branch knows the exact member.
type Fish = { swim: () => string; name: string };
type Bird = { fly: () => string; name: string };
// Custom type guard — note the 'pet is Fish' return type:
function isFish(pet: Fish | Bird): pet is Fish {
return typeof (pet as Fish).swim === "function";
}
function move(pet: Fish | Bird): string {
if (isFish(pet)) return pet.swim(); // pet: Fish here
return pet.fly(); // pet: Bird here
}// A CUSTOM TYPE GUARD is a function whose return type is "x is Foo".
// In TypeScript: function isCat(a: Dog | Cat): a is Cat { ... }
// When it returns true, TypeScript treats the argument as that type.
// At runtime the guard is just a boolean-returning function:
function isFish(pet) {
// The real check: does it have a 'swim' method?
return typeof pet.swim === "function";
}
const fish = { swim: () => "glub", name: "Nemo" };
const bird = { fly: () => "flap", name: "Tweety" };
function move(pet) {
if (isFish(pet)) {
return pet.name + " swims: " + pet.swim(); // treated as a Fish
}
return pet.name + " flies: " + pet.fly(); // treated as a Bird
}
console.log(move(fish)); // Nemo swims: glub
console.log(move(bird)); // Tweety flies: flap
// --- discriminant check: switch on a shared literal 'kind' field ---
function summarise(event) {
switch (event.kind) {
case "click": return "clicked at (" + event.x + "," + event.y + ")";
case "key": return "pressed " + event.key;
}
}
console.log(summarise({ kind: "click", x: 10, y: 20 })); // clicked at (10,20)
console.log(summarise({ kind: "key", key: "Enter" })); // pressed Enter
// In a .ts file: function isFish(pet: Fish | Bird): pet is Fish { ... }🎯 Your Turn
Write len, which returns the length of a string or array and 0 for anything else. Fill in the two blanks marked ___, then run it.
// 🎯 YOUR TURN — replace each ___ then press "Try it Yourself".
// 'len' returns the length of a string OR an array (both have .length),
// but should return 0 for anything else. Narrow with typeof and Array.isArray.
function len(value) {
if (typeof value === ___) { // 👉 the string "string"
return value.length; // a string's length
}
if (Array.isArray(value)) {
return value.___; // 👉 also .length
}
return 0; // not a string or array
}
console.log(len("hello")); // 5
console.log(len([1, 2, 3])); // 3
console.log(len(42)); // 0
// ✅ Expected output:
// 5
// 3
// 0Common Errors (and the fix)
- ❌ "Property 'purr' does not exist on type 'Dog | Cat'" — you used a method before narrowing. ✅ Add if (animal instanceof Cat) first, then call purr().
- ❌ Guard returns boolean, no narrowing happens — you forgot the predicate. ✅ Change the return type to pet is Fish so TypeScript narrows on a true result.
- ❌ Truthiness check drops valid 0 — if (count) skips 0. ✅ Use if (count !== undefined) when zero is allowed.
- ❌ "typeof null is 'object'" — narrowing arrays with typeof x === "object" also matches null and objects. ✅ Use Array.isArray(x) for arrays specifically.
Pro Tips
- 💡 Prefer discriminated unions with a kind field — switching on it is the cleanest, most reliable narrowing you can write.
- 💡 Name guards clearly (isFish, isError) and keep their runtime check honest — a lying guard silently breaks type safety.
- 💡 Use Array.isArray rather than typeof to detect arrays — typeof [] === "object", which isn't specific enough.
- 💡 Add an exhaustiveness check: a default branch with const _exhaustive: never = x makes the compiler flag any union member you forgot.
Mini-Challenge: Handle an API Result
No blanks this time — just a brief and a starting outline. Write the type guard and the handler yourself, run it, and check your output against the example in the comments.
// 🎯 MINI-CHALLENGE: a custom type guard + discriminant narrowing
// You receive an "API result" that is ONE of:
// { ok: true, data: string }
// { ok: false, error: string }
//
// 1. Write isSuccess(res) that returns true when res.ok is true
// (this is your runtime type guard).
// 2. Write handle(res): if isSuccess(res) return "Got: " + res.data,
// otherwise return "Error: " + res.error.
// 3. Call handle on one success and one failure and log both.
//
// ✅ Expected output:
// Got: user#42
// Error: not found
// your code here🎉 Lesson Complete
- ✅ Narrowing refines a broad type to a specific one inside a block
- ✅ typeof handles primitives; truthiness checks remove null/undefined/empty
- ✅ instanceof narrows class instances; in narrows object shapes
- ✅ A custom type guard returns arg is Type to drive narrowing
- ✅ A discriminant field (kind) makes switch branching effortless
Practice quiz
What is type narrowing?
- Shrinking a string's length
- Removing a type alias
- Refining a broad type to a more specific one inside a block based on a runtime check
- Deleting union members permanently
Answer: Refining a broad type to a more specific one inside a block based on a runtime check. After a check like typeof x === 'string', TypeScript treats x as string in that branch.
Which narrowing tool is best for primitive types?
- typeof
- instanceof
- the 'in' operator
- Array.isArray
Answer: typeof. typeof distinguishes 'string', 'number', 'boolean', and other primitives.
What does a truthiness check like 'if (name)' remove from the type?
- Only null
- Only undefined
- Nothing
- null, undefined, and falsy values like '' and 0
Answer: null, undefined, and falsy values like '' and 0. It rules out all falsy values, including null, undefined, '', and 0.
Which operator narrows class instances?
- typeof
- instanceof
- in
- is
Answer: instanceof. if (animal instanceof Cat) lets you call Cat-only methods in that branch.
What does the 'in' operator check during narrowing?
- Whether a property exists on an object
- Whether a value is in an array
- Whether a class extends another
- The type of a primitive
Answer: Whether a property exists on an object. 'radius' in shape distinguishes a circle from a rectangle by a property.
What makes a function a custom type guard?
- It returns boolean
- It uses instanceof
- Its return type is a predicate like 'x is Foo'
- It is named with 'is'
Answer: Its return type is a predicate like 'x is Foo'. The 'arg is Type' return type tells the compiler to narrow when the guard returns true.
If a guard returns plain 'boolean' instead of 'x is Foo', what happens?
- It still narrows
- TypeScript does NOT narrow the variable
- It errors
- It narrows to never
Answer: TypeScript does NOT narrow the variable. Without the predicate, the compiler sees a boolean and won't narrow, even with identical runtime logic.
Why use Array.isArray(x) instead of typeof x === 'object' to detect arrays?
- typeof [] is 'array'
- Array.isArray is slower
- typeof cannot run on arrays
- typeof [] === 'object', which also matches null and plain objects
Answer: typeof [] === 'object', which also matches null and plain objects. Arrays report 'object' under typeof, so Array.isArray is the specific check.
What is the cleanest narrowing for a union sharing a literal 'kind' field?
- Many instanceof checks
- A discriminant check - switch on the 'kind' field
- as assertions
- A try/catch
Answer: A discriminant check - switch on the 'kind' field. Switching on a shared discriminant field gives the compiler precise per-branch types.
Why is 'if (count)' risky when 0 is a valid value?
- It throws on 0
- 0 becomes a string
- Truthiness skips 0 along with null/undefined
- It always returns false
Answer: Truthiness skips 0 along with null/undefined. Truthiness drops 0; use 'if (count !== undefined)' when zero is legitimate.
Continue this course
- Previous: Enums
- Next: Generics — Write reusable, type-safe functions, interfaces and classes
- Quick reference: TypeScript cheat sheet › Functions & Generics
Frequently asked questions
What is type narrowing?
Narrowing is when TypeScript refines a broad type (like string | number) to a more specific one inside a block of code, based on a runtime check you wrote. After if (typeof x === "string"), TypeScript treats x as a string for the rest of that branch, so string methods become safe to call.
When do I need a custom type guard instead of typeof or instanceof?
typeof works for primitives and instanceof works for class instances, but neither can distinguish two plain object shapes. A custom type guard — a function returning the predicate 'arg is SomeType' — lets you encode any check you like (e.g. 'has a swim method') and have TypeScript narrow based on its result.
What does the 'x is Foo' return type actually do?
It is a type predicate. At runtime the function just returns a boolean, but the 'x is Foo' annotation tells the compiler: 'if this returned true, treat the argument as Foo from here on.' Without it, TypeScript would see a plain boolean and would NOT narrow the variable.
Why is the 'in' operator useful for narrowing?
The in operator checks whether a property exists on an object ('radius' in shape). For a union of object shapes that don't share a class, checking for a distinctive property is a clean way to tell them apart, and TypeScript narrows the union accordingly inside the branch.