TypeScript Type Guards and Narrowing Explained
Learn how TypeScript type guards and control flow narrowing work, including typeof, instanceof, in, discriminated unions, and custom type predicates.
What you'll learn
- ✓How TypeScript narrows types through control flow analysis
- ✓All built-in type guard mechanisms and when to use each
- ✓How to write custom type predicates for complex types
- ✓Discriminated union patterns for exhaustive type checking
Prerequisites
- •TypeScript basics
- •Union types and interfaces
TypeScript’s type system tracks the possible types of a variable as it flows through your code. When you write a conditional check, TypeScript narrows the type in each branch. This is called control flow analysis, and type guards are the checks that trigger narrowing.
typeof Guards
The simplest type guard. TypeScript understands typeof checks for primitives:
function formatValue(value: string | number | boolean): string {
if (typeof value === "string") {
return value.toUpperCase(); // value is string here
}
if (typeof value === "number") {
return value.toFixed(2); // value is number here
}
return value ? "Yes" : "No"; // value is boolean here
}
TypeScript narrows the type in each branch. After the first if, the remaining type is number | boolean. After the second if, only boolean remains.
typeof works for: "string", "number", "boolean", "bigint", "symbol", "undefined", "object", and "function".
Watch out for null:
function process(value: string | null) {
if (typeof value === "object") {
// value is still string | null here!
// typeof null === "object" is a famous JavaScript quirk
}
if (value !== null) {
value.toUpperCase(); // value is string here
}
}
Truthiness Narrowing
TypeScript narrows based on truthiness checks:
function printName(name: string | null | undefined) {
if (name) {
console.log(name.toUpperCase()); // name is string
}
}
But be careful with values that are falsy but valid:
function processCount(count: number | null) {
if (count) {
// count is number, but 0 is excluded!
console.log(count);
}
// Better: explicit null check
if (count !== null) {
console.log(count); // count is number, including 0
}
}
Equality Narrowing
TypeScript narrows when you compare values with ===, !==, ==, or !=:
function example(x: string | number, y: string | boolean) {
if (x === y) {
// x and y must both be string (the only shared type)
console.log(x.toUpperCase());
console.log(y.toUpperCase());
}
}
function checkStatus(status: "active" | "inactive" | "pending") {
if (status !== "active") {
// status is "inactive" | "pending"
}
}
The == null check narrows both null and undefined:
function process(value: string | null | undefined) {
if (value != null) {
// value is string (both null and undefined removed)
console.log(value.toUpperCase());
}
}
instanceof Guards
For class instances and built-in objects:
function handleError(error: Error | string) {
if (error instanceof Error) {
console.log(error.message); // error is Error
console.log(error.stack);
} else {
console.log(error); // error is string
}
}
function processDate(input: string | Date) {
if (input instanceof Date) {
return input.toISOString(); // input is Date
}
return new Date(input).toISOString(); // input is string
}
The in Operator
Checks if a property exists on an object, narrowing the type:
interface Dog {
bark(): void;
breed: string;
}
interface Cat {
meow(): void;
indoor: boolean;
}
function handlePet(pet: Dog | Cat) {
if ("bark" in pet) {
pet.bark(); // pet is Dog
console.log(pet.breed);
} else {
pet.meow(); // pet is Cat
console.log(pet.indoor);
}
}
The in operator works well when the types have distinct properties. If both types share all property names, use a discriminant instead.
Discriminated Unions
The most powerful narrowing pattern. Add a literal type property that uniquely identifies each variant:
interface Circle {
kind: "circle";
radius: number;
}
interface Rectangle {
kind: "rectangle";
width: number;
height: number;
}
interface Triangle {
kind: "triangle";
base: number;
height: number;
}
type Shape = Circle | Rectangle | Triangle;
function getArea(shape: Shape): number {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2;
case "rectangle":
return shape.width * shape.height;
case "triangle":
return (shape.base * shape.height) / 2;
}
}
Each case narrows shape to the specific variant. TypeScript knows all properties available in each branch.
Exhaustive Checking
Add a default case that catches unhandled variants at compile time:
function getArea(shape: Shape): number {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2;
case "rectangle":
return shape.width * shape.height;
case "triangle":
return (shape.base * shape.height) / 2;
default:
const _exhaustive: never = shape;
throw new Error(`Unhandled shape: ${_exhaustive}`);
}
}
If you add a new shape variant to the union but forget to handle it in the switch, the assignment to never fails at compile time. This catches missing cases before they reach production.
Custom Type Predicates
For complex type checks, write a function that returns a type predicate:
interface Fish {
swim(): void;
name: string;
}
interface Bird {
fly(): void;
name: string;
}
function isFish(pet: Fish | Bird): pet is Fish {
return "swim" in pet;
}
function handlePet(pet: Fish | Bird) {
if (isFish(pet)) {
pet.swim(); // pet is Fish
} else {
pet.fly(); // pet is Bird
}
}
The pet is Fish return type annotation is the type predicate. It tells TypeScript that when the function returns true, the argument is Fish.
Filtering Arrays with Type Predicates
Type predicates are essential for typed array filtering:
interface SuccessResponse {
status: "success";
data: unknown;
}
interface ErrorResponse {
status: "error";
message: string;
}
type Response = SuccessResponse | ErrorResponse;
function isSuccess(response: Response): response is SuccessResponse {
return response.status === "success";
}
const responses: Response[] = [
{ status: "success", data: { id: 1 } },
{ status: "error", message: "Not found" },
{ status: "success", data: { id: 2 } },
];
const successes = responses.filter(isSuccess);
// type: SuccessResponse[]
const data = successes.map(r => r.data);
// type: unknown[]
Without the type predicate, filter would return Response[] and you would lose the narrowing.
Filtering Out Null and Undefined
function isNonNull<T>(value: T): value is NonNullable<T> {
return value != null;
}
const mixed: (string | null | undefined)[] = ["hello", null, "world", undefined];
const strings = mixed.filter(isNonNull);
// type: string[]
Assertion Functions
Assertion functions narrow types by throwing when the check fails:
function assertIsString(value: unknown): asserts value is string {
if (typeof value !== "string") {
throw new TypeError(`Expected string, got ${typeof value}`);
}
}
function processInput(input: unknown) {
assertIsString(input);
// input is string from here onward
console.log(input.toUpperCase());
}
Unlike type predicates that work in if conditions, assertions narrow the type for all code after the assertion call:
function assertDefined<T>(
value: T,
message?: string
): asserts value is NonNullable<T> {
if (value == null) {
throw new Error(message ?? "Value is null or undefined");
}
}
function getUser(id: number): User | null {
return users.get(id) ?? null;
}
const user = getUser(1);
assertDefined(user, "User not found");
console.log(user.name); // user is User, not User | null
Narrowing with Assignment
TypeScript tracks assignments and narrows accordingly:
let value: string | number;
value = "hello";
console.log(value.toUpperCase()); // value is string
value = 42;
console.log(value.toFixed(2)); // value is number
Narrowing in Callbacks and Closures
Type narrowing does not always persist in callbacks:
function process(value: string | null) {
if (value !== null) {
// value is string here
setTimeout(() => {
// value is still string | null here!
// TypeScript cannot guarantee the value has not changed
console.log(value.toUpperCase()); // Error in strict mode
}, 1000);
}
}
The fix is to capture the narrowed value in a const:
function process(value: string | null) {
if (value !== null) {
const narrowed = value; // const - cannot be reassigned
setTimeout(() => {
console.log(narrowed.toUpperCase()); // OK, narrowed is string
}, 1000);
}
}
Real-World Pattern: API Response Handling
Combining discriminated unions with type guards for API responses:
type ApiResult<T> =
| { ok: true; data: T }
| { ok: false; error: { code: number; message: string } };
async function fetchApi<T>(url: string): Promise<ApiResult<T>> {
try {
const response = await fetch(url);
if (!response.ok) {
return {
ok: false,
error: { code: response.status, message: response.statusText },
};
}
const data = await response.json();
return { ok: true, data };
} catch (err) {
return {
ok: false,
error: { code: 0, message: err instanceof Error ? err.message : "Unknown error" },
};
}
}
async function loadUser(id: number) {
const result = await fetchApi<User>(`/api/users/${id}`);
if (!result.ok) {
console.error(`Error ${result.error.code}: ${result.error.message}`);
return;
}
console.log(result.data.name); // result.data is User
}
Wrapping Up
TypeScript narrowing works through control flow analysis. The type system tracks which checks you have performed and narrows types accordingly. Use typeof for primitives, instanceof for class instances, in for property checks, and discriminated unions for tagged object types. Custom type predicates (is) and assertion functions (asserts) let you encapsulate complex checks in reusable functions. The exhaustive check pattern with never catches unhandled union variants at compile time, making your code safer as types evolve.
Related articles
- TypeScript TypeScript Narrowing and Control Flow Analysis
Learn how TypeScript narrows types through control flow, using typeof, instanceof, in, and discriminated unions to write safer code.
- TypeScript TypeScript Type Guards Deep Dive
Master TypeScript type guards: typeof, instanceof, discriminated unions, user-defined predicates, and assertion functions. Narrow types safely and idiomatically.
- TypeScript TypeScript Generics: Advanced Patterns and Constraints
Master advanced TypeScript generics patterns including conditional constraints, recursive types, generic factories, and type-safe builder patterns with examples.
- TypeScript The TypeScript satisfies Operator and When to Use It
Learn how the satisfies operator validates types without widening, preserving literal inference for config objects and lookup tables.