Type Guards: Runtime Type Checking in TypeScript


What Are Type Guards
Think of a type guard like a security checkpoint at an airport. The checkpoint doesn't change who you are — it just confirms your identity so you can be treated accordingly on the other side. TypeScript's type guards work the same way: they perform a runtime check on a value, and if the check passes, TypeScript narrows the type so you can work with it safely.
In the last post, you learned about literal types and as const — compile-time tools for constraining values to exact possibilities. But TypeScript's knowledge of types ends at compilation. When your application runs, a function might receive a string | number, an API response might be typed as unknown, or a class hierarchy might include several subclasses. Type guards let you inspect values at runtime and tell TypeScript — with full type safety — what you're working with.
Type Narrowing vs Type Guards
Type narrowing is the broader concept: TypeScript automatically narrows
types inside conditionals, after null checks, and after assignments. Type
guards are the specific checks that trigger narrowing — either built-in
operators like typeof and instanceof, or custom functions you write with a
special return type called a type predicate.
The typeof Operator
The most common type guard you'll write is a simple typeof check. TypeScript understands the result of typeof and narrows the type accordingly inside the if block:
function formatValue(value: string | number): string {
if (typeof value === "string") {
// TypeScript knows: value is string here
return value.toUpperCase();
}
// TypeScript knows: value is number here
return value.toFixed(2);
}
formatValue("hello"); // "HELLO"
formatValue(3.14159); // "3.14"TypeScript recognises these typeof results and narrows accordingly: "string", "number", "boolean", "bigint", "symbol", "undefined", "object", and "function".
The narrowing is precise — once TypeScript has ruled out a type inside one branch, it removes it from the type in subsequent branches:
function describe(value: string | number | boolean): string {
if (typeof value === "string") {
return `String of length ${value.length}`;
}
if (typeof value === "number") {
return `Number: ${value.toFixed(0)}`;
}
// TypeScript knows: only boolean remains
return `Boolean: ${value ? "yes" : "no"}`;
}typeof null Is object
A long-standing JavaScript quirk: typeof null === "object". This means
typeof alone cannot distinguish null from a real object. Always add an
explicit !== null check when working with nullable object types: if (value !== null && typeof value === "object").
The instanceof Operator
For class instances, instanceof is the right tool. TypeScript narrows a value to the specific class type when an instanceof check passes:
class Dog {
bark(): void {
console.log("Woof!");
}
}
class Cat {
purr(): void {
console.log("Purr...");
}
}
function makeSound(animal: Dog | Cat): void {
if (animal instanceof Dog) {
// TypeScript knows: animal is Dog here
animal.bark();
} else {
// TypeScript knows: animal is Cat here
animal.purr();
}
}instanceof works with any class in your codebase, and it also works with built-in classes like Date, Error, Map, and Array:
function processError(err: unknown): string {
if (err instanceof Error) {
// TypeScript knows: err is Error here
return `Error: ${err.message}`;
}
if (err instanceof TypeError) {
// TypeError extends Error — this works too
return `Type Error: ${err.message}`;
}
return "Unknown error occurred";
}This is especially useful in catch blocks, where the caught value has type unknown in strict TypeScript. Using instanceof Error is the idiomatic way to safely access .message and .stack.
Custom Type Guard Functions
typeof and instanceof cover primitives and class instances, but they can't help you with plain objects, interfaces, or complex shapes. That's where custom type guard functions come in. A custom type guard is a regular function that returns a boolean — but you call it to narrow a type:
interface Bird {
species: string;
canFly: boolean;
wingSpan: number;
}
interface Fish {
species: string;
canFly: false;
waterType: "fresh" | "salt";
}
function isBird(animal: Bird | Fish): boolean {
return "wingSpan" in animal;
}
function describeAnimal(animal: Bird | Fish): void {
if (isBird(animal)) {
// TypeScript does NOT narrow here — it still sees Bird | Fish
console.log(animal.wingSpan); // Error: property 'wingSpan' does not exist on type 'Fish'
}
}The problem: a function returning boolean doesn't carry any type information. TypeScript can't know that true means "it's a Bird". To fix this, you need a type predicate.
User-Defined Type Guards with Type Predicates
A type predicate is a special return type annotation in the form parameter is Type. It tells TypeScript: "if this function returns true, then the specified parameter is the given type."
Here's the fixed version of the example above:
function isBird(animal: Bird | Fish): animal is Bird {
return "wingSpan" in animal;
}
function describeAnimal(animal: Bird | Fish): void {
if (isBird(animal)) {
// TypeScript knows: animal is Bird here
console.log(`${animal.species} with a ${animal.wingSpan}cm wingspan`);
console.log(`Can fly: ${animal.canFly}`);
} else {
// TypeScript knows: animal is Fish here
console.log(`${animal.species} lives in ${animal.waterType} water`);
}
}The return type animal is Bird is the type predicate. The parameter name before is must match one of the function's parameter names exactly.
Type predicates are particularly valuable when validating data from external sources like API responses:
interface ApiUser {
id: string;
email: string;
role: "admin" | "user" | "guest";
}
function isApiUser(data: unknown): data is ApiUser {
return (
typeof data === "object" &&
data !== null &&
"id" in data &&
"email" in data &&
"role" in data &&
typeof (data as ApiUser).id === "string" &&
typeof (data as ApiUser).email === "string"
);
}
async function fetchUser(id: string): Promise<ApiUser> {
const response = await fetch(`/api/users/${id}`);
const data: unknown = await response.json();
if (!isApiUser(data)) {
throw new Error("Invalid user data from API");
}
// TypeScript knows: data is ApiUser here
return data;
}Consider a Validation Library for Complex Guards
For production applications with many API shapes, hand-writing type guards becomes tedious. Libraries like Zod, Valibot, and io-ts let you define schemas that both validate at runtime and infer TypeScript types automatically. They generate type guard functions for you under the hood.
Narrowing with Discriminated Unions
Type guards work especially well with discriminated unions — union types where each member has a shared property with a unique literal type. TypeScript can use a simple equality check on the discriminant to narrow the full type:
type LoadingState = {
status: "loading";
};
type SuccessState = {
status: "success";
data: string[];
};
type ErrorState = {
status: "error";
message: string;
};
type AppState = LoadingState | SuccessState | ErrorState;
function renderState(state: AppState): string {
switch (state.status) {
case "loading":
// TypeScript knows: state is LoadingState
return "Loading...";
case "success":
// TypeScript knows: state is SuccessState
return `Loaded ${state.data.length} items`;
case "error":
// TypeScript knows: state is ErrorState
return `Error: ${state.message}`;
default:
// TypeScript knows this is unreachable if all cases are covered
const exhaustiveCheck: never = state;
return exhaustiveCheck;
}
}The status property is the discriminant — a literal type that uniquely identifies each union member. No instanceof or custom predicates needed. TypeScript narrows entirely on equality checks against the discriminant.
Exhaustiveness Checking with never
The default: const x: never = state pattern is a compile-time safety net. If
you ever add a new member to AppState without updating renderState,
TypeScript will report an error: it can no longer assign the new type to
never. This catches missing cases before they become runtime bugs.
Type Guard Best Practices
Choose the right tool for the job:
typeof— for primitive types (string,number,boolean,undefined)instanceof— for class instances and built-in classesinoperator — for checking if a property exists on an object- Custom type predicates — for complex shapes, interfaces, and API data
- Discriminated unions — when you control the type definitions
Write honest type guards. A type guard function that lies — returning true when the type check is actually wrong — defeats the entire purpose and creates hard-to-debug runtime errors. Make sure your predicate logic genuinely verifies the shape you claim:
// Bad — incomplete check, lies about the type
function isUser(value: unknown): value is ApiUser {
return typeof value === "object" && value !== null;
}
// Good — checks all required properties and their types
function isUser(value: unknown): value is ApiUser {
return (
typeof value === "object" &&
value !== null &&
typeof (value as ApiUser).id === "string" &&
typeof (value as ApiUser).email === "string" &&
["admin", "user", "guest"].includes((value as ApiUser).role)
);
}Never Lie to TypeScript
A dishonest type predicate — one that claims value is SomeType without fully
validating the shape — moves runtime errors from TypeScript-detectable
locations to somewhere deep in your application logic. If you're not sure how
to validate a field, it's better to throw an error than to return true
incorrectly.
Combine type guards with early returns to keep your logic flat and readable:
function processPayment(amount: string | number): number {
if (typeof amount === "string") {
const parsed = parseFloat(amount);
if (isNaN(parsed)) throw new Error(`Invalid amount: ${amount}`);
return parsed;
}
return amount; // TypeScript knows it's number here
}Key Takeaways
- Type guards narrow a broad type to a specific one inside a conditional block — TypeScript tracks the narrowing automatically
typeofnarrows primitive types; remembertypeof null === "object"and add explicit null checksinstanceofnarrows class instances and works with built-in classes likeError,Date, andMap- Custom type predicates use the
value is Typereturn annotation to tell TypeScript what atrueresult means - Discriminated unions with a
statusorkindfield allow narrowing with simple equality checks and enable exhaustiveness checking vianever - Write honest type guards — an incorrect predicate creates runtime bugs that TypeScript can't catch
You Can Now Bridge Compile Time and Runtime
Type guards complete the picture for working with union types: you know how to
define them with |, combine them with &, constrain them to literals, and
now narrow them precisely at runtime. Next up: discriminated unions and tagged
types — a pattern that formalises everything you just learned into a
systematic approach for modelling application state.