What are type guards? Implement a custom type guard function and explain how TypeScript narrows types.

7 minadvancedtypescripttypeguardscustomguard

Quick Answer

Type guards are expressions that perform runtime checks to narrow down the type of a variable within a specific scope. They help TypeScript understand the actual type of a value at runtime, enabling better type safety and IntelliSense.

Detailed Answer

What are type guards? Implement a custom type guard function and explain how TypeScript narrows types.

Answer: Type guards are expressions that perform runtime checks to narrow down the type of a variable within a specific scope. They help TypeScript understand the actual type of a value at runtime, enabling better type safety and IntelliSense.

How Type Narrowing Works: TypeScript uses control flow analysis to narrow types based on certain conditions. When a type guard returns true, TypeScript narrows the type in the subsequent code block.

Built-in Type Guards:

  1. typeof Guards:
function processValue(value: string | number) {
  if (typeof value === 'string') {
    // TypeScript knows value is string here
    console.log(value.toUpperCase()); // OK
    console.log(value.length); // OK
  } else {
    // TypeScript knows value is number here
    console.log(value.toFixed(2)); // OK
    console.log(value * 2); // OK
  }
}
  1. instanceof Guards:
function processError(error: Error | string) {
  if (error instanceof Error) {
    // TypeScript knows error is Error here
    console.log(error.message);
    console.log(error.stack);
  } else {
    // TypeScript knows error is string here
    console.log(error.toUpperCase());
  }
}
  1. in Guards:
interface Bird {
  fly(): void;
  layEggs(): void;
}

interface Fish {
  swim(): void;
  layEggs(): void;
}

function move(animal: Bird | Fish) {
  if ('fly' in animal) {
    // TypeScript knows animal is Bird
    animal.fly();
  } else {
    // TypeScript knows animal is Fish
    animal.swim();
  }
}
  1. Equality Guards:
function processValue(value: string | null | undefined) {
  if (value === null) {
    // TypeScript knows value is null
    console.log('Value is null');
  } else if (value === undefined) {
    // TypeScript knows value is undefined
    console.log('Value is undefined');
  } else {
    // TypeScript knows value is string
    console.log(value.toUpperCase());
  }
}

Custom Type Guards:

Type guards are functions that return a type predicate: value is Type.

// Basic custom type guard
function isString(value: unknown): value is string {
  return typeof value === 'string';
}

function processUnknown(value: unknown) {
  if (isString(value)) {
    // TypeScript knows value is string
    console.log(value.toUpperCase());
  }
}

// More complex type guard
interface User {
  id: number;
  name: string;
  email: string;
}

interface Admin {
  id: number;
  name: string;
  permissions: string[];
}

function isUser(obj: any): obj is User {
  return obj && 
         typeof obj.id === 'number' && 
         typeof obj.name === 'string' && 
         typeof obj.email === 'string' &&
         !('permissions' in obj);
}

function isAdmin(obj: any): obj is Admin {
  return obj && 
         typeof obj.id === 'number' && 
         typeof obj.name === 'string' && 
         Array.isArray(obj.permissions);
}

function processPerson(person: User | Admin) {
  if (isUser(person)) {
    // TypeScript knows person is User
    console.log(`User email: ${person.email}`);
  } else if (isAdmin(person)) {
    // TypeScript knows person is Admin
    console.log(`Admin permissions: ${person.permissions.join(', ')}`);
  }
}

Advanced Type Guard Patterns:

  1. Discriminated Union Guards:
interface LoadingState {
  status: 'loading';
}

interface SuccessState {
  status: 'success';
  data: any;
}

interface ErrorState {
  status: 'error';
  error: string;
}

type AppState = LoadingState | SuccessState | ErrorState;

function handleState(state: AppState) {
  switch (state.status) {
    case 'loading':
      // TypeScript knows state is LoadingState
      console.log('Loading...');
      break;
    case 'success':
      // TypeScript knows state is SuccessState
      console.log('Data:', state.data);
      break;
    case 'error':
      // TypeScript knows state is ErrorState
      console.log('Error:', state.error);
      break;
  }
}
  1. Array Type Guards:
function isStringArray(value: unknown): value is string[] {
  return Array.isArray(value) && value.every(item => typeof item === 'string');
}

function processArray(value: unknown) {
  if (isStringArray(value)) {
    // TypeScript knows value is string[]
    value.forEach(str => console.log(str.toUpperCase()));
  }
}
  1. Generic Type Guards:
function isOfType<T>(
  value: unknown,
  type: string
): value is T {
  return typeof value === type;
}

function isNumber(value: unknown): value is number {
  return isOfType<number>(value, 'number');
}

function isBoolean(value: unknown): value is boolean {
  return isOfType<boolean>(value, 'boolean');
}
  1. Complex Object Validation:
interface ApiResponse {
  success: boolean;
  data?: any;
  error?: string;
}

function isApiResponse(obj: unknown): obj is ApiResponse {
  return (
    typeof obj === 'object' &&
    obj !== null &&
    'success' in obj &&
    typeof (obj as any).success === 'boolean'
  );
}

function handleApiResponse(response: unknown) {
  if (isApiResponse(response)) {
    if (response.success) {
      // TypeScript knows response.data exists
      console.log('Data:', response.data);
    } else {
      // TypeScript knows response.error exists
      console.log('Error:', response.error);
    }
  }
}
  1. Branded Types with Type Guards:
// Branded types for additional type safety
type UserId = number & { readonly __brand: 'UserId' };
type ProductId = number & { readonly __brand: 'ProductId' };

function createUserId(id: number): UserId {
  if (id <= 0) {
    throw new Error('Invalid user ID');
  }
  return id as UserId;
}

function createProductId(id: number): ProductId {
  if (id <= 0) {
    throw new Error('Invalid product ID');
  }
  return id as ProductId;
}

function isUserId(value: unknown): value is UserId {
  return typeof value === 'number' && value > 0;
}

function processId(id: UserId | ProductId) {
  if (isUserId(id)) {
    // TypeScript knows id is UserId
    console.log('Processing user ID:', id);
  } else {
    // TypeScript knows id is ProductId
    console.log('Processing product ID:', id);
  }
}

Type Guard Best Practices:

  1. Use Type Predicates:
// Good - uses type predicate
function isString(value: unknown): value is string {
  return typeof value === 'string';
}

// Avoid - doesn't narrow types
function isString(value: unknown): boolean {
  return typeof value === 'string';
}
  1. Combine Multiple Guards:
function isValidUser(obj: unknown): obj is User {
  return (
    typeof obj === 'object' &&
    obj !== null &&
    'id' in obj &&
    'name' in obj &&
    'email' in obj &&
    typeof (obj as any).id === 'number' &&
    typeof (obj as any).name === 'string' &&
    typeof (obj as any).email === 'string'
  );
}
  1. Use Assertion Functions:
function assertIsString(value: unknown): asserts value is string {
  if (typeof value !== 'string') {
    throw new Error('Expected string');
  }
}

function processValue(value: unknown) {
  assertIsString(value);
  // TypeScript knows value is string after assertion
  console.log(value.toUpperCase());
}
  1. Create Reusable Guard Libraries:
// Type guard utilities
export const TypeGuards = {
  isString: (value: unknown): value is string => typeof value === 'string',
  isNumber: (value: unknown): value is number => typeof value === 'number',
  isBoolean: (value: unknown): value is boolean => typeof value === 'boolean',
  isObject: (value: unknown): value is object => typeof value === 'object' && value !== null,
  isArray: (value: unknown): value is unknown[] => Array.isArray(value),
  isFunction: (value: unknown): value is Function => typeof value === 'function',
  isNull: (value: unknown): value is null => value === null,
  isUndefined: (value: unknown): value is undefined => value === undefined,
  isNullish: (value: unknown): value is null | undefined => value == null,
};

Common Pitfalls:

  1. Don't forget the type predicate:
// Wrong - doesn't narrow types
function isString(value: unknown): boolean {
  return typeof value === 'string';
}

// Correct - narrows types
function isString(value: unknown): value is string {
  return typeof value === 'string';
}
  1. Be careful with any:
// Avoid using any in type guards
function badGuard(value: any): value is User {
  return value.name && value.email; // Too permissive
}

// Better - be specific
function goodGuard(value: unknown): value is User {
  return (
    typeof value === 'object' &&
    value !== null &&
    'name' in value &&
    'email' in value &&
    typeof (value as any).name === 'string' &&
    typeof (value as any).email === 'string'
  );
}

Type guards are essential for working with dynamic data, API responses, and user input where types aren't known at compile time. They provide runtime type safety while maintaining TypeScript's compile-time benefits.