Type Guards and Runtime Type Checking

1. typeof and instanceof Type Guards

Guard Type Syntax Use Case Narrows To
typeof typeof x === 'type' Check primitive types and functions at runtime string, number, boolean, symbol, undefined, object, function
instanceof x instanceof Class Check if object is instance of a class Specific class type
typeof null typeof x === 'object' ⚠️ Returns 'object' for null (JavaScript quirk) Need additional null check
typeof array typeof [] === 'object' ⚠️ Arrays return 'object', use Array.isArray() Use Array.isArray() instead

Example: typeof type guards

function processValue(value: string | number | boolean) {
    if (typeof value === 'string') {
        // Type narrowed to string
        return value.toUpperCase();
    }
    
    if (typeof value === 'number') {
        // Type narrowed to number
        return value.toFixed(2);
    }
    
    // Type narrowed to boolean
    return value ? 'yes' : 'no';
}

// Function type check
function callIfFunction(fn: unknown) {
    if (typeof fn === 'function') {
        // Type narrowed to Function
        fn();
    }
}

// Handle null and object
function processObject(value: unknown) {
    if (typeof value === 'object' && value !== null) {
        // Type narrowed to object (excluding null)
        console.log('Is object:', value);
    }
}

Example: instanceof type guards

class Dog {
    bark() { console.log('Woof!'); }
}

class Cat {
    meow() { console.log('Meow!'); }
}

function makeSound(animal: Dog | Cat) {
    if (animal instanceof Dog) {
        // Type narrowed to Dog
        animal.bark();
    } else {
        // Type narrowed to Cat
        animal.meow();
    }
}

// Built-in classes
function processDate(value: Date | string) {
    if (value instanceof Date) {
        // Type narrowed to Date
        return value.toISOString();
    }
    // Type narrowed to string
    return new Date(value).toISOString();
}

// Error handling
function handleError(error: unknown) {
    if (error instanceof Error) {
        // Type narrowed to Error
        console.error(error.message, error.stack);
    } else {
        console.error('Unknown error:', error);
    }
}

2. User-Defined Type Guards and Predicates

Feature Syntax Description Use Case
Type Predicate x is Type Custom function returning type predicate - tells TS about narrowing Complex validation logic
Return Type function(x): x is T Return type must be boolean with is predicate Type guard function signature
Parameter Name paramName is Type Must match parameter name in function signature Type guard reference
Narrowing Scope After guard call Type narrowed only in if/else blocks after guard check Control flow analysis

Example: Basic type predicates

interface Fish {
    swim: () => void;
}

interface Bird {
    fly: () => void;
}

// User-defined type guard
function isFish(animal: Fish | Bird): animal is Fish {
    return (animal as Fish).swim !== undefined;
}

function move(animal: Fish | Bird) {
    if (isFish(animal)) {
        // Type narrowed to Fish
        animal.swim();
    } else {
        // Type narrowed to Bird
        animal.fly();
    }
}

// String validation guard
function isValidEmail(str: string): str is string {
    return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(str);
}

function sendEmail(email: string) {
    if (isValidEmail(email)) {
        // Type still string, but validated
        console.log(`Sending to ${email}`);
    } else {
        throw new Error('Invalid email');
    }
}

Example: Narrowing unknown types

// Check if value is string
function isString(value: unknown): value is string {
    return typeof value === 'string';
}

// Check if value is number
function isNumber(value: unknown): value is number {
    return typeof value === 'number' && !isNaN(value);
}

// Check if value is array
function isArray<T>(value: unknown): value is T[] {
    return Array.isArray(value);
}

// Complex object validation
interface User {
    id: number;
    name: string;
    email: string;
}

function isUser(value: unknown): value is User {
    return (
        typeof value === 'object' &&
        value !== null &&
        'id' in value &&
        'name' in value &&
        'email' in value &&
        typeof (value as any).id === 'number' &&
        typeof (value as any).name === 'string' &&
        typeof (value as any).email === 'string'
    );
}

// Usage
function processData(data: unknown) {
    if (isUser(data)) {
        // Type narrowed to User
        console.log(data.name, data.email);
    }
}

Example: Generic type guards

// Generic array element guard
function isArrayOf<T>(
    value: unknown,
    guard: (item: unknown) => item is T
): value is T[] {
    return Array.isArray(value) && value.every(guard);
}

// Usage with primitives
const data: unknown = [1, 2, 3];
if (isArrayOf(data, isNumber)) {
    // Type narrowed to number[]
    data.forEach(n => console.log(n.toFixed(2)));
}

// Nullable type guard
function isNonNullable<T>(value: T): value is NonNullable<T> {
    return value !== null && value !== undefined;
}

function processValue(value: string | null | undefined) {
    if (isNonNullable(value)) {
        // Type narrowed to string
        return value.toUpperCase();
    }
}

3. Assertion Functions and asserts Keyword

Feature Syntax Description Behavior
asserts condition asserts condition Assert boolean condition - throws if false Narrows type in rest of scope
asserts x is Type asserts x is T Assert parameter is specific type - throws if not Type narrowing after call
Return Type void or never Assertion functions return void or throw (never returns) Side effect: type narrowing
Throw Behavior Must throw on failure Function must throw or process.exit on assertion failure Compiler assumes success

Example: Basic assertion functions

// Assert condition is true
function assert(condition: unknown, msg?: string): asserts condition {
    if (!condition) {
        throw new Error(msg || 'Assertion failed');
    }
}

function processValue(value: string | null) {
    assert(value !== null, 'Value cannot be null');
    // After assert, type narrowed to string
    console.log(value.toUpperCase());
}

// Assert type
function assertIsString(value: unknown): asserts value is string {
    if (typeof value !== 'string') {
        throw new Error('Value must be a string');
    }
}

function handleInput(input: unknown) {
    assertIsString(input);
    // After assertion, type narrowed to string
    console.log(input.trim());
}

Example: Non-null assertions

// Assert value is not null or undefined
function assertNonNullable<T>(
    value: T,
    message?: string
): asserts value is NonNullable<T> {
    if (value === null || value === undefined) {
        throw new Error(message || 'Value is null or undefined');
    }
}

function getUser(id: string): User | null {
    // ... database lookup
    return null;
}

function displayUser(id: string) {
    const user = getUser(id);
    assertNonNullable(user, 'User not found');
    // Type narrowed from User | null to User
    console.log(user.name, user.email);
}

// Assert array has elements
function assertNotEmpty<T>(
    array: T[],
    message?: string
): asserts array is [T, ...T[]] {
    if (array.length === 0) {
        throw new Error(message || 'Array is empty');
    }
}

function processItems(items: string[]) {
    assertNotEmpty(items);
    // Type narrowed to [string, ...string[]] (non-empty tuple)
    const first = items[0]; // Type is string, not string | undefined
}

Example: Complex type assertions

interface User {
    id: number;
    name: string;
    email: string;
}

// Assert object matches interface
function assertIsUser(value: unknown): asserts value is User {
    if (
        typeof value !== 'object' ||
        value === null ||
        typeof (value as any).id !== 'number' ||
        typeof (value as any).name !== 'string' ||
        typeof (value as any).email !== 'string'
    ) {
        throw new Error('Invalid user object');
    }
}

// API response handling
async function fetchUser(id: string): Promise<User> {
    const response = await fetch(`/api/users/${id}`);
    const data: unknown = await response.json();
    
    assertIsUser(data);
    // Type narrowed to User after assertion
    return data;
}

// Discriminated union assertion
type Success = { status: 'success'; data: string };
type Failure = { status: 'error'; error: string };
type Result = Success | Failure;

function assertSuccess(result: Result): asserts result is Success {
    if (result.status !== 'success') {
        throw new Error('Expected success result');
    }
}

function handleResult(result: Result) {
    assertSuccess(result);
    // Type narrowed to Success
    console.log(result.data);
}
Note: Use type predicates (x is T) when you want to check and branch. Use assertion functions (asserts x is T) when failure should terminate execution. Assertions are great for preconditions and invariants.

4. Discriminated Union Type Guards

Component Description Example Purpose
Discriminant Property Common literal property across union members type: 'success' | 'error' Distinguish union variants
Literal Type Unique literal value per variant 'success', 'error' Enable type narrowing
Switch Statement Pattern match on discriminant switch(value.type) { ... } Exhaustive checking
Exhaustiveness Check Ensure all cases handled const _: never = value; Compile-time completeness

Example: Basic discriminated unions

// Tagged union with discriminant property
type Shape =
    | { kind: 'circle'; radius: number }
    | { kind: 'rectangle'; width: number; height: number }
    | { kind: 'square'; size: number };

function getArea(shape: Shape): number {
    switch (shape.kind) {
        case 'circle':
            // Type narrowed to { kind: 'circle'; radius: number }
            return Math.PI * shape.radius ** 2;
        
        case 'rectangle':
            // Type narrowed to { kind: 'rectangle'; width: number; height: number }
            return shape.width * shape.height;
        
        case 'square':
            // Type narrowed to { kind: 'square'; size: number }
            return shape.size ** 2;
        
        default:
            // Exhaustiveness check
            const _exhaustive: never = shape;
            throw new Error('Unknown shape');
    }
}

// If statements work too
function describe(shape: Shape): string {
    if (shape.kind === 'circle') {
        return `Circle with radius ${shape.radius}`;
    }
    if (shape.kind === 'rectangle') {
        return `Rectangle ${shape.width}x${shape.height}`;
    }
    return `Square ${shape.size}x${shape.size}`;
}

Example: API response handling

// Success/Error discriminated union
type ApiResponse<T> =
    | { status: 'loading' }
    | { status: 'success'; data: T }
    | { status: 'error'; error: string };

function handleResponse<T>(response: ApiResponse<T>) {
    switch (response.status) {
        case 'loading':
            console.log('Loading...');
            break;
        
        case 'success':
            // response.data is accessible and typed as T
            console.log('Data:', response.data);
            break;
        
        case 'error':
            // response.error is accessible and typed as string
            console.error('Error:', response.error);
            break;
    }
}

// Redux-style action types
type Action =
    | { type: 'INCREMENT'; by: number }
    | { type: 'DECREMENT'; by: number }
    | { type: 'RESET' }
    | { type: 'SET'; value: number };

function reducer(state: number, action: Action): number {
    switch (action.type) {
        case 'INCREMENT':
            return state + action.by;
        case 'DECREMENT':
            return state - action.by;
        case 'RESET':
            return 0;
        case 'SET':
            return action.value;
        default:
            // Exhaustiveness check catches missing cases
            const _: never = action;
            return state;
    }
}

Example: Multiple discriminants

// Multiple discriminant properties
type NetworkState =
    | { state: 'idle' }
    | { state: 'loading'; progress: number }
    | { state: 'success'; data: string; cached: boolean }
    | { state: 'error'; error: string; retryable: boolean };

function handleNetworkState(state: NetworkState) {
    // Primary discriminant
    switch (state.state) {
        case 'idle':
            console.log('Idle');
            break;
        
        case 'loading':
            console.log(`Loading: ${state.progress}%`);
            break;
        
        case 'success':
            // Secondary check on cached
            if (state.cached) {
                console.log('From cache:', state.data);
            } else {
                console.log('Fresh data:', state.data);
            }
            break;
        
        case 'error':
            if (state.retryable) {
                console.log('Retrying...', state.error);
            } else {
                console.error('Fatal error:', state.error);
            }
            break;
    }
}

// Nested discriminated unions
type PaymentMethod =
    | { type: 'card'; card: { number: string; cvv: string } }
    | { type: 'paypal'; email: string }
    | { type: 'crypto'; wallet: string; currency: 'BTC' | 'ETH' };

function processPayment(method: PaymentMethod) {
    if (method.type === 'crypto') {
        // Further narrow by currency
        if (method.currency === 'BTC') {
            console.log('Bitcoin payment to', method.wallet);
        } else {
            console.log('Ethereum payment to', method.wallet);
        }
    }
}

5. in Operator for Property Existence Checking

Feature Syntax Description Use Case
in Operator 'prop' in obj Check if property exists in object at runtime Narrow union types by properties
Type Narrowing After in check TypeScript narrows to types containing property Discriminate union members
Optional Properties property?: type Property might be undefined but still "in" object Check existence vs value
Inherited Properties Prototype chain in checks own and inherited properties Use hasOwnProperty for own only

Example: Basic in operator narrowing

interface Cat {
    meow: () => void;
    purr: () => void;
}

interface Dog {
    bark: () => void;
    fetch: () => void;
}

type Pet = Cat | Dog;

function interact(pet: Pet) {
    if ('meow' in pet) {
        // Type narrowed to Cat
        pet.meow();
        pet.purr();
    } else {
        // Type narrowed to Dog
        pet.bark();
        pet.fetch();
    }
}

// Multiple property checks
function feedPet(pet: Pet) {
    if ('bark' in pet) {
        // Dog
        console.log('Feeding dog');
    } else if ('meow' in pet) {
        // Cat
        console.log('Feeding cat');
    }
}

Example: Complex union narrowing

type Vehicle =
    | { type: 'car'; wheels: 4; engine: string }
    | { type: 'bike'; wheels: 2; pedals: boolean }
    | { type: 'boat'; propeller: string };

function describe(vehicle: Vehicle) {
    // Check for specific property
    if ('engine' in vehicle) {
        // Narrowed to car
        console.log(`Car with ${vehicle.wheels} wheels and ${vehicle.engine}`);
    } else if ('pedals' in vehicle) {
        // Narrowed to bike
        console.log(`Bike with ${vehicle.pedals ? 'pedals' : 'no pedals'}`);
    } else {
        // Narrowed to boat
        console.log(`Boat with ${vehicle.propeller}`);
    }
}

// API response with optional fields
interface SuccessResponse {
    success: true;
    data: string;
}

interface ErrorResponse {
    success: false;
    error: string;
    code?: number;
}

type Response = SuccessResponse | ErrorResponse;

function handleResponse(response: Response) {
    if ('error' in response) {
        // Narrowed to ErrorResponse
        console.error('Error:', response.error);
        if ('code' in response && response.code) {
            console.error('Error code:', response.code);
        }
    } else {
        // Narrowed to SuccessResponse
        console.log('Data:', response.data);
    }
}

Example: Combining with other guards

// Combining in with typeof
function processValue(value: unknown) {
    if (typeof value === 'object' && value !== null) {
        if ('length' in value) {
            // Likely an array or array-like
            console.log('Length:', (value as any).length);
        }
        
        if ('toString' in value) {
            // Has toString method (most objects)
            console.log('String:', value.toString());
        }
    }
}

// Check method existence
interface Flyable {
    fly: () => void;
}

interface Swimmable {
    swim: () => void;
}

type Creature = Flyable | Swimmable | (Flyable & Swimmable);

function move(creature: Creature) {
    const canFly = 'fly' in creature;
    const canSwim = 'swim' in creature;
    
    if (canFly && canSwim) {
        console.log('Can fly and swim');
        creature.fly();
        creature.swim();
    } else if (canFly) {
        creature.fly();
    } else {
        creature.swim();
    }
}

6. Control Flow Analysis and Type Narrowing

Technique Description Example Pattern Reliability
Truthiness Narrowing if(x) narrows from union with null/undefined/false/0/"" if (x) { /* x is truthy */ } Simple but imprecise
Equality Narrowing === and !== narrow to/from specific values if (x === null) { } Precise
Assignment Narrowing Type narrowed after assignment in same scope x = 'string'; /* x is string */ Within scope only
Early Return Return/throw narrows type in rest of function if (!x) return; /* x is truthy */ Very reliable
Switch Fallthrough Type narrowed through switch cases switch(x.type) { case 'a': } Reliable with discriminants
Exception Narrowing throw removes possibility from control flow if (!x) throw Error(); Reliable

Example: Truthiness and equality narrowing

// Truthiness narrowing
function processValue(value: string | null | undefined) {
    if (value) {
        // Type narrowed to string (null and undefined are falsy)
        console.log(value.toUpperCase());
    }
}

// Be careful with 0 and empty string
function processNumber(value: number | null) {
    if (value) {
        // ⚠️ 0 is falsy, so this might not work as expected
        console.log(value.toFixed(2));
    }
    
    // Better: explicit null check
    if (value !== null) {
        // Type narrowed to number (includes 0)
        console.log(value.toFixed(2));
    }
}

// Equality narrowing
function compare(x: string | number, y: string | number) {
    if (x === y) {
        // Both are same type (string or number)
        console.log(x, y);
    }
}

// Discriminant equality
type Result = 
    | { success: true; value: number }
    | { success: false; error: string };

function handle(result: Result) {
    if (result.success === true) {
        // Narrowed to success case
        console.log(result.value);
    } else {
        // Narrowed to error case
        console.log(result.error);
    }
}

Example: Control flow with early returns

// Early return pattern
function processUser(user: User | null | undefined): string {
    // Guard clause
    if (!user) {
        return 'No user';
    }
    
    // After guard, user is definitely User
    if (!user.email) {
        return 'No email';
    }
    
    // user and user.email both exist
    return user.email.toLowerCase();
}

// Multiple guards
function validateInput(input: string | null | undefined): string {
    if (input === null) {
        throw new Error('Input is null');
    }
    
    if (input === undefined) {
        throw new Error('Input is undefined');
    }
    
    // Type narrowed to string
    if (input.length === 0) {
        throw new Error('Input is empty');
    }
    
    // Type is string with length > 0
    return input.trim();
}

// Nested narrowing
function processData(data: unknown): number {
    if (typeof data !== 'object' || data === null) {
        throw new Error('Not an object');
    }
    
    // Type narrowed to object
    if (!('value' in data)) {
        throw new Error('No value property');
    }
    
    const value = (data as any).value;
    
    if (typeof value !== 'number') {
        throw new Error('Value is not a number');
    }
    
    // Type narrowed to number
    return value;
}

Example: Assignment and control flow

// Type narrowing through assignment
function demo() {
    let x: string | number;
    
    x = 'hello';
    // Type narrowed to string
    console.log(x.toUpperCase());
    
    x = 42;
    // Type narrowed to number
    console.log(x.toFixed(2));
}

// Conditional assignment
function processOptional(value?: string) {
    let result: string;
    
    if (value !== undefined) {
        result = value;
        // result is string, value is string
    } else {
        result = 'default';
    }
    
    // result is always string here
    console.log(result.toUpperCase());
}

// Type guards with assignments
function findUser(id: string): User | undefined {
    // ... lookup logic
    return undefined;
}

function displayUser(id: string) {
    const user = findUser(id);
    
    if (!user) {
        console.log('User not found');
        return;
    }
    
    // user is definitely User here
    console.log(user.name);
    
    // Can reassign
    const admin: User | Admin = user;
    if ('permissions' in admin) {
        // Narrowed to Admin
        console.log(admin.permissions);
    }
}

Example: Complex control flow scenarios

// Unreachable code detection
function handleValue(value: string | number) {
    if (typeof value === 'string') {
        return value.toUpperCase();
    }
    
    // Type narrowed to number
    return value.toFixed(2);
    
    // This would be detected as unreachable
    // console.log('Never executed');
}

// Exhaustiveness with control flow
type Status = 'pending' | 'approved' | 'rejected';

function processStatus(status: Status): string {
    if (status === 'pending') {
        return 'Waiting...';
    }
    
    if (status === 'approved') {
        return 'Approved!';
    }
    
    if (status === 'rejected') {
        return 'Rejected!';
    }
    
    // TypeScript knows this is unreachable
    // status has type 'never' here
    const _exhaustive: never = status;
    return _exhaustive;
}

// Type narrowing with logical operators
function process(value: string | null | undefined) {
    // Using || for defaults
    const str = value || 'default';
    // Type is string
    
    // Using ?? for null/undefined only
    const str2 = value ?? 'default';
    // Type is string
    
    // Using && for chaining
    const result = value && value.toUpperCase();
    // Type is string | null | undefined | false
}
Note: TypeScript's control flow analysis tracks type narrowing through:
  • Conditional statements (if/else, switch, ternary)
  • Early exits (return, throw, break, continue)
  • Type guards (typeof, instanceof, in, user-defined)
  • Assignments within the same scope
Enable strictNullChecks for better control flow analysis.
Warning: Control flow analysis doesn't work across:
  • Function boundaries - type guards don't propagate to inner functions
  • Async boundaries - type narrowing lost after await
  • Array methods - callbacks don't maintain outer scope narrowing
  • Object mutations - changing properties doesn't narrow types

Type Guards Summary

  • typeof/instanceof - Built-in guards for primitives and classes
  • Type predicates (x is T) - Custom guards that return boolean
  • Assertion functions (asserts x is T) - Guards that throw on failure
  • Discriminated unions - Use literal property to distinguish variants
  • in operator - Check property existence to narrow unions
  • Control flow - TypeScript tracks narrowing through conditionals and exits