Type Narrowing and Guards in TypeScript
Type narrowing is the process of refining types from broader to more specific. When you have a union type, TypeScript doesn’t know which exact type you’re working with at runtime. Type narrowing gives you the tools to help TypeScript understand your code and catch more bugs at compile time.
Understanding Type Narrowing
TypeScript starts with a wider type and narrows it down based on conditions you check. The most common narrowing happens with the typeof operator:
function printValue(value: string | number) {
if (typeof value === "string") {
// TypeScript knows value is a string here
console.log(value.toUpperCase());
} else {
// TypeScript knows value is a number here
console.log(value.toFixed(2));
}
}
This is simple but powerful. TypeScript sees the typeof check and narrows the type inside each branch.
The instanceof Operator
When working with classes, instanceof works similarly to typeof:
class Dog {
bark() {
console.log("Woof!");
}
}
class Cat {
meow() {
console.log("Meow!");
}
}
function makeSound(animal: Dog | Cat) {
if (animal instanceof Dog) {
animal.bark();
} else {
animal.meow();
}
}
Custom Type Guards
Sometimes built-in checks aren’t enough. You can create custom type guards by returning a type predicate:
interface Fish {
swim(): void;
}
interface Bird {
fly(): void;
}
function isFish(animal: Fish | Bird): animal is Fish {
return (animal as Fish).swim !== undefined;
}
function move(animal: Fish | Bird) {
if (isFish(animal)) {
animal.swim();
} else {
animal.fly();
}
}
The animal is Fish return type tells TypeScript that if the function returns true, the variable is narrowed to Fish.
The in Operator
The in operator checks if a property exists on an object:
interface Circle {
kind: "circle";
radius: number;
}
interface Square {
kind: "square";
side: number;
}
type Shape = Circle | Square;
function getArea(shape: Shape) {
if ("radius" in shape) {
return Math.PI * shape.radius ** 2;
}
return shape.side ** 2;
}
Discriminated Unions
A common pattern is adding a discriminator property to each variant:
type Result =
| { status: "success"; data: string }
| { status: "error"; message: string };
function handleResult(result: Result) {
switch (result.status) {
case "success":
console.log(result.data);
break;
case "error":
console.error(result.message);
break;
}
}
The status property lets TypeScript narrow the type precisely in each branch.
Truthiness Narrowing
TypeScript also narrows based on truthiness:
function printLength(str: string | null) {
if (str) {
console.log(str.length);
} else {
console.log("No string provided");
}
}
Using Type Assertions
When you know more than TypeScript can infer, use non-null assertions or type assertions:
function getFirstElement(arr: number[]): number {
return arr[0]; // TypeScript sees number | undefined
}
function getFirstElementSafe(arr: number[]): number {
return arr[0]!; // Assert it's not undefined
}
Be careful with assertions—they bypass TypeScript’s safety net.
Practice Example
Here’s a real-world example combining these techniques:
type ApiResponse =
| { type: "user"; name: string; email: string }
| { type: "post"; title: string; content: string }
| { type: "error"; code: number; message: string };
function processResponse(response: ApiResponse) {
if (response.type === "user") {
return `User: ${response.name} (${response.email})`;
}
if (response.type === "post") {
return `Post: ${response.title}`;
}
return `Error ${response.code}: ${response.message}`;
}
Summary
Type narrowing helps TypeScript understand your runtime logic:
- Use
typeoffor primitives - Use
instanceoffor class instances - Create custom type guards with
animal is Typepredicates - Use
infor property checking - Prefer discriminated unions when possible
- Be careful with assertions—they bypass safety checks
Mastering type narrowing makes your TypeScript code both safer and more express
When Narrowing Fails
Sometimes TypeScript cannot narrow automatically. This happens when the runtime check doesn’t provide enough information for TypeScript to infer the type. In these cases, you need to be explicit.
Type Predicates vs Boolean Returns
A common mistake is returning a boolean instead of a type predicate:
// ❌ This doesn't narrow - returns boolean
function isString(value: unknown): boolean {
return typeof value === "string";
}
// ✅ This narrows - returns type predicate
function isString(value: unknown): value is string {
return typeof value === "string";
}
When you use the first version, TypeScript has no idea what happened inside the function. With the second version, TypeScript narrows the type after the check.
Exhaustiveness Checking
TypeScript can help you ensure you’ve handled all cases in a union type. Use a function that never returns:
type Shape =
| { kind: "circle"; radius: number }
| { kind: "square"; side: number }
| { kind: "triangle"; base: number; height: number };
function getArea(shape: Shape): number {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2;
case "square":
return shape.side ** 2;
case "triangle":
return (shape.base * shape.height) / 2;
default:
// If TypeScript sees this, you missed a case
const _exhaustive: never = shape;
throw new Error(`Unknown shape: ${_exhaustive}`);
}
}
This pattern ensures that when you add a new shape type, TypeScript will flag the switch statement as incomplete.
Best Practices
- Prefer discriminated unions - Add a common property like
typeorkindto each variant - Use type predicates for complex checks - When simple checks aren’t enough
- Enable strict null checks - This makes narrowing more important and useful
- Avoid excessive use of type assertions - They defeat the purpose of type safety
- Document your type guards - Explain what each guard checks and what it guarantees
Type narrowing is one of TypeScript’s most powerful features. It lets you write safe code while keeping the flexibility of JavaScript’s dynamic nature.