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. However, typeof only works for the seven primitive types — it cannot tell two different classes apart, and it returns "object" for anything that is not a primitive. When you need to distinguish between object types, a different narrowing mechanism is required.
The instanceof Operator
When working with classes, instanceof works similarly to typeof, but it inspects the prototype chain instead of the primitive type tag:
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();
}
}
The instanceof operator checks the prototype chain, so it works well with classes and built-in types like Error or Map. But when you are working with plain objects or interfaces — which have no runtime representation — instanceof falls short. That is where custom type guards become necessary.
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. Type predicates work well when you need to encapsulate complex runtime logic, such as checking nested properties or validating data shapes. For simpler checks — where you just need to confirm that a property exists — there is a more direct approach.
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;
}
Property checks with in are straightforward, but they can become fragile when objects share property names. A Square and a Rectangle both have a side property, so in alone cannot distinguish them. For shapes with overlapping fields, a deliberate design pattern works better.
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. Discriminated unions are the most reliable narrowing pattern because they encode the type variant as a literal value. TypeScript can exhaustively check every branch, and you get a compile error if you miss a case. For simpler scenarios where the union only distinguishes between a value existing or not, a more basic check suffices.
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");
}
}
Truthiness narrowing is convenient for null checks, but it cannot distinguish between null, undefined, 0, an empty string, or false — all are falsy. When you need to be precise about which falsy value you are guarding against, an explicit === null or === undefined check is clearer to both TypeScript and future readers.
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. Assertions are useful when you have outside knowledge that TypeScript cannot verify — for example, when you know an element exists in the DOM after a successful querySelector call. But every assertion is a place where the compiler trusts you without evidence, so use them sparingly and prefer guards when the check is straightforward to express.
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 expressive.
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.
Writing guards that stay readable
The best type guards are easy to explain out loud. If a guard has to inspect many fields, nested arrays, or several possible shapes, consider splitting the check into smaller named helpers. That keeps the narrowing logic easier to review and gives future readers a clear idea of what each guard is proving.
Readability matters because guards become part of the contract for the rest of the code. Other functions will trust the result and branch on it, so a guard should be precise about what it accepts. A guard that is too broad can hide bugs, while one that is too narrow can reject valid data and make the surrounding code harder to use.
Designing good discriminants
Discriminated unions work best when the discriminator is stable, short, and obvious. A field like kind, type, or status usually works well because it is easy to check and easy to document. When possible, give each variant a unique literal value and keep the rest of the shape focused on the data that variant actually needs.
That structure makes control flow much easier to follow. Once a branch checks the discriminator, the remaining code can work with a narrower object and stop worrying about unrelated properties. The result is code that reads like a set of small cases instead of a series of defensive guesses.
Debugging narrowing failures
If TypeScript refuses to narrow a value the way you expect, look at the runtime check first. The compiler can only use information that the code actually proves. A condition that returns a boolean, but does not connect that boolean to a type predicate, gives TypeScript no extra detail to work with. Clear annotations usually fix that gap.
It also helps to simplify the branch until the behavior is obvious. If a guard is wrapped in several helper functions, test each helper on its own. Narrowing problems are often caused by one small mismatch between the runtime check and the type signature, not by the whole feature being wrong. Small examples make the mismatch easier to spot.
A quick checklist
Before you ship a narrowing-heavy function, ask whether the branches are named clearly, whether each guard proves one thing, and whether a future reader can add a new union member without rewriting everything. If the answer is yes, the design is in good shape. If not, the type probably needs a cleaner shape or a clearer discriminator.
Narrowing in public APIs
Public APIs should make narrowing easy for their callers. That means returning shapes with clear discriminators, avoiding unnecessary any values, and keeping optional fields meaningful. If the API gives callers a clean union, they can branch confidently without adding a lot of extra checks.
This also improves long-term maintenance. When the API shape is obvious, future changes are easier to model and easier to test. The caller can rely on the type information instead of guessing at runtime behavior, which is exactly where TypeScript brings the most value.