Overview
Type guards let you narrow a broad type into a specific one inside a conditional block. They bridge the gap between TypeScript's compile-time types and JavaScript's runtime values, which is essential when handling unknown, union types, and external data.
Why Type Guards Matter
Consider a value that could be a string or a number:
function format(value: string | number) {
return value.toUpperCase(); // Error: toUpperCase does not exist on number
}
TypeScript refuses to compile because toUpperCase is not available on number. A type guard narrows the type so the call is safe.
Built-in Narrowing Techniques
typeof
function format(value: string | number): string {
if (typeof value === "string") {
return value.toUpperCase();
}
return value.toFixed(2);
}
typeof result | Matches |
|---|---|
"string" | String primitives |
"number" | Numbers (including NaN) |
"boolean" | true / false |
"object" | Objects, arrays, null (careful) |
"function" | Functions |
"undefined" | undefined |
instanceof
class ApiError extends Error {
constructor(public status: number, message: string) {
super(message);
}
}
function handle(err: unknown) {
if (err instanceof ApiError) {
console.log(err.status); // narrowed to ApiError
} else if (err instanceof Error) {
console.log(err.message); // narrowed to Error
}
}
in
type Admin = { role: "admin"; permissions: string[] };
type User = { role: "user"; email: string };
function describe(person: Admin | User) {
if ("permissions" in person) {
return `Admin with ${person.permissions.length} permissions`;
}
return `User with email ${person.email}`;
}
Discriminated Unions
The most reliable pattern: give each variant a literal field that TypeScript can discriminate on.
type Result =
| { status: "success"; data: string }
| { status: "error"; message: string }
| { status: "loading" };
function render(result: Result) {
switch (result.status) {
case "success":
return result.data; // narrowed to success variant
case "error":
return result.message; // narrowed to error variant
case "loading":
return "Loading...";
}
}
Discriminated unions also power exhaustiveness checks:
function assertNever(value: never): never {
throw new Error(`Unhandled case: ${JSON.stringify(value)}`);
}
function render(result: Result) {
switch (result.status) {
case "success": return result.data;
case "error": return result.message;
case "loading": return "Loading...";
default: return assertNever(result);
}
}
If you add a new variant to Result, the default branch fails to compile until it is handled.
Custom Type Guards
A user-defined type guard returns a type predicate: value is Type.
interface Product {
id: number;
name: string;
price: number;
}
function isProduct(value: unknown): value is Product {
return (
typeof value === "object" &&
value !== null &&
"id" in value &&
"name" in value &&
"price" in value &&
typeof (value as Product).id === "number" &&
typeof (value as Product).name === "string" &&
typeof (value as Product).price === "number"
);
}
const data: unknown = JSON.parse(response);
if (isProduct(data)) {
console.log(data.price); // typed as number
}
Assertion Functions
An assertion function throws on failure and narrows the type for all following code.
function assertIsString(value: unknown): asserts value is string {
if (typeof value !== "string") {
throw new TypeError("Expected a string");
}
}
function process(input: unknown) {
assertIsString(input);
console.log(input.toUpperCase()); // input is string from here on
}
Array Type Guards
Filtering an array of mixed types requires a type predicate; a plain boolean filter does not narrow.
const values: (string | number)[] = ["a", 1, "b", 2];
// Wrong: result is still (string | number)[]
const strings = values.filter(v => typeof v === "string");
// Correct: result is string[]
function isString(v: unknown): v is string {
return typeof v === "string";
}
const strings2 = values.filter(isString);
Type Guard Comparison
| Technique | Best for | Limitations |
|---|---|---|
typeof | Primitives | null is "object" |
instanceof | Class instances | Fails across realms and for interfaces |
in | Object shape checks | Does not verify value types |
| Discriminated union | Variants with a literal tag | Requires upfront design |
| Custom predicate | External or unknown data | Runtime cost, must be correct |
| Assertion function | Early validation | Throws rather than branching |
Validation Libraries
For complex shapes, a schema library is less error-prone than hand-written guards:
| Library | Approach |
|---|---|
| Zod | Schema-first with z.infer for types |
| Valibot | Modular, small bundle size |
| io-ts | Functional, codec-based |
| ArkType | Type syntax at runtime |
import { z } from "zod";
const ProductSchema = z.object({
id: z.number(),
name: z.string(),
price: z.number().positive(),
});
type Product = z.infer<typeof ProductSchema>;
const parsed = ProductSchema.safeParse(json);
if (parsed.success) {
console.log(parsed.data.price); // typed as number
}
Common Pitfalls
- Trusting
typeof null === "object". Always checkvalue !== nullfirst. - Using
asinstead of a guard. Type assertions do not validate at runtime. - Filtering without a predicate. The result stays a union type.
- Assuming
instanceofworks across iframes or worker boundaries. It does not. - Writing guards that are too permissive. A guard that returns
trueincorrectly is worse than no guard.
