Type Guards and Assertion Functions
When Built-in Narrowing Isn't Enough
TypeScript's typeof, instanceof, and in checks handle most narrowing. But what about complex types that can't be distinguished by a single runtime check? What about validating API responses, checking discriminated unions from external sources, or asserting invariants? That's where type guards and assertion functions come in.
Think of type guards as custom passport control. TypeScript's built-in narrowing is like automated gates — they check simple things (typeof, instanceof). Type guards are human inspectors — they can check anything, and when they stamp "approved," TypeScript trusts their verdict. The is keyword is the stamp that says "I've verified this is type X." The asserts keyword is a stamp that says "If I return at all, this is definitely type X — otherwise I throw."
Type Predicates with is
A type predicate is a function that returns a boolean and tells TypeScript what type the argument is when the return is true:
interface Fish { swim: () => void }
interface Bird { fly: () => void }
// Type predicate — return type is "value is Type"
function isFish(animal: Fish | Bird): animal is Fish {
return "swim" in animal;
}
function move(animal: Fish | Bird) {
if (isFish(animal)) {
animal.swim(); // animal: Fish — narrowed by type predicate
} else {
animal.fly(); // animal: Bird — narrowed by elimination
}
}
Without the is Fish return type, TypeScript wouldn't narrow — it would just see a boolean return:
// Without type predicate — no narrowing
function isFish(animal: Fish | Bird): boolean {
return "swim" in animal;
}
function move(animal: Fish | Bird) {
if (isFish(animal)) {
animal.swim(); // ERROR: Property 'swim' does not exist on type 'Fish | Bird'
}
}
Practical Type Guards
// Null check guard
function isNonNull<T>(value: T | null | undefined): value is T {
return value !== null && value !== undefined;
}
const items = [1, null, 2, undefined, 3];
const clean = items.filter(isNonNull); // clean: number[] — nulls removed
// Object shape guard
interface ApiError {
code: string;
message: string;
details?: unknown;
}
function isApiError(value: unknown): value is ApiError {
return (
typeof value === "object" &&
value !== null &&
"code" in value &&
"message" in value &&
typeof (value as ApiError).code === "string" &&
typeof (value as ApiError).message === "string"
);
}
// Array element guard
function isString(value: unknown): value is string {
return typeof value === "string";
}
const mixed: unknown[] = [1, "hello", true, "world"];
const strings = mixed.filter(isString); // strings: string[]
Type guards are trusted by the compiler unconditionally. If your implementation is wrong, TypeScript still narrows based on your is declaration:
// DANGEROUS — lying type guard
function isString(value: unknown): value is string {
return true; // Always returns true — lies to TypeScript
}
const x: unknown = 42;
if (isString(x)) {
x.toUpperCase(); // TypeScript thinks this is safe — runtime crash!
}The is keyword is a contract between you and the compiler. TypeScript trusts you completely. If the runtime check doesn't actually verify the type, you've introduced a bug that the type system can't catch.
Assertion Functions with asserts
Assertion functions narrow types by throwing on failure rather than returning a boolean:
// Assertion function — throws or narrows
function assertIsString(value: unknown): asserts value is string {
if (typeof value !== "string") {
throw new Error(`Expected string, got ${typeof value}`);
}
}
function processInput(input: unknown) {
assertIsString(input);
// After assertion — input is string for the rest of this scope
console.log(input.toUpperCase()); // OK — narrowed to string
}
// Assert non-null
function assertDefined<T>(value: T | null | undefined, name: string): asserts value is T {
if (value === null || value === undefined) {
throw new Error(`${name} must be defined`);
}
}
function handleUser(user: User | null) {
assertDefined(user, "user");
// user is User for the rest of this function
console.log(user.name);
}
asserts vs is — when to use each
Use is (type predicate) when:
- You're checking in a conditional (
if, ternary, filter callback) - Both the true and false branches need to do different things
- You want the non-narrowed type available in the else branch
Use asserts (assertion function) when:
- Invalid state should throw immediately
- You want to narrow for the rest of the scope, not just an if branch
- You're validating preconditions at function entry
// is — good for branching
function handle(value: string | number) {
if (isString(value)) {
// string branch
} else {
// number branch — both branches are useful
}
}
// asserts — good for preconditions
function handle(value: unknown) {
assertIsString(value); // Throws if not string
// Everything below is string — no branching needed
return value.toUpperCase();
}Exhaustive Checking with never
The never type represents an impossible state. In the default case of a switch over a discriminated union, if all cases are handled, the value's type is never:
type Action =
| { type: "INCREMENT"; amount: number }
| { type: "DECREMENT"; amount: number }
| { type: "RESET" };
function assertNever(value: never, message?: string): never {
throw new Error(message ?? `Unexpected value: ${JSON.stringify(value)}`);
}
function reducer(state: number, action: Action): number {
switch (action.type) {
case "INCREMENT":
return state + action.amount;
case "DECREMENT":
return state - action.amount;
case "RESET":
return 0;
default:
return assertNever(action); // Compile error if a case is missing
}
}
If someone adds { type: "SET"; value: number } to the Action union without updating the reducer, the assertNever(action) line will error because the SET variant hasn't been eliminated and action isn't never in the default case.
Production Scenario: Runtime Schema Validation
// Type-safe configuration loader with assertion guards
interface DatabaseConfig {
host: string;
port: number;
name: string;
ssl: boolean;
}
function assertDatabaseConfig(value: unknown): asserts value is DatabaseConfig {
if (typeof value !== "object" || value === null) {
throw new Error("Config must be an object");
}
const obj = value as Record<string, unknown>;
if (typeof obj.host !== "string") throw new Error("host must be a string");
if (typeof obj.port !== "number") throw new Error("port must be a number");
if (typeof obj.name !== "string") throw new Error("name must be a string");
if (typeof obj.ssl !== "boolean") throw new Error("ssl must be a boolean");
if (obj.port < 1 || obj.port > 65535) {
throw new Error("port must be between 1 and 65535");
}
}
// Usage — parse environment config safely
function loadConfig(): DatabaseConfig {
const raw = JSON.parse(process.env.DB_CONFIG ?? "{}");
assertDatabaseConfig(raw); // Throws with descriptive error if invalid
return raw; // TypeScript knows this is DatabaseConfig
}
// Combining type guards with filter for arrays
interface PublishedPost {
id: string;
title: string;
publishedAt: Date;
status: "published";
}
interface DraftPost {
id: string;
title: string;
status: "draft";
}
type Post = PublishedPost | DraftPost;
function isPublished(post: Post): post is PublishedPost {
return post.status === "published";
}
const allPosts: Post[] = getPosts();
const published: PublishedPost[] = allPosts.filter(isPublished);
// published is PublishedPost[] — filter + type guard narrows the array
-
Wrong: Writing type guards that don't actually verify the type at runtime Right: Every type guard must genuinely check the shape — never return true unconditionally
-
Wrong: Using as (type assertion) when a type guard would be safer Right: Write a type guard function that validates at runtime, not a cast that only satisfies the compiler
-
Wrong: Forgetting to use type guards with Array.filter() Right: Pass a type guard to filter to narrow the array element type: arr.filter(isString) → string[]
-
Wrong: Not exhaustively checking discriminated unions in reducers/handlers Right: Always add a default case with assertNever to catch unhandled variants
Type-Safe Event Validator
- 1Type predicates (value is Type) narrow in conditional branches — use for if/else branching and filter callbacks
- 2Assertion functions (asserts value is Type) narrow for the rest of the scope — use for preconditions and validation
- 3Type guards are trusted unconditionally — ensure your runtime check actually verifies the type
- 4Use assertNever in switch default cases for exhaustive checking of discriminated unions
- 5Array.filter with a type guard narrows the array element type — the only way to narrow arrays in TypeScript