Designing Scalable TypeScript Systems: Handling Complex Error Propagation Patterns

Learn how to design scalable TypeScript applications by effectively handling complex error propagation patterns with practical techniques and examples.

When building scalable TypeScript systems, managing errors becomes crucial, especially as your application grows in complexity. Proper error propagation ensures that your application stays robust and maintainable. This article introduces beginner-friendly techniques to handle complex error patterns in TypeScript.

### Why is error handling important in scalable systems? Many errors in large systems come from unexpected states, failed external calls, or invalid inputs. Properly propagating these errors through layers lets you react appropriately—logging details, recovering when possible, or informing the user.

### Using TypeScript's built-in Error class. The most straightforward way is using try-catch with standard Error objects. Customizing error classes for specific cases helps distinguish errors when propagating upward.

typescript
class DatabaseError extends Error {
  constructor(message: string) {
    super(message);
    this.name = 'DatabaseError';
  }
}

function fetchUser(id: number) {
  if (id <= 0) {
    throw new DatabaseError('Invalid user ID');
  }
  // Simulate database fetch
  return { id, name: 'Alice' };
}

function getUserProfile(id: number) {
  try {
    const user = fetchUser(id);
    return `User: ${user.name}`;
  } catch (error) {
    if (error instanceof DatabaseError) {
      console.error('Database issue:', error.message);
      // Propagate or transform
      throw new Error('Failed to get user profile');
    }
    throw error;  // Unknown error
  }
}

### Creating a standardized error type for better propagation. Instead of throwing raw errors, use a structured approach that includes an error code, message, and optionally metadata. This pattern is especially helpful in large teams.

typescript
interface AppError {
  code: string;
  message: string;
  details?: any;
}

function createError(code: string, message: string, details?: any): AppError {
  return { code, message, details };
}

function validateInput(input: string): AppError | null {
  if (!input) {
    return createError('VALIDATION_ERROR', 'Input cannot be empty');
  }
  return null;
}

function process(input: string): string | AppError {
  const error = validateInput(input);
  if (error) return error;
  return `Processed: ${input}`;
}

const result = process('');
if ('code' in result) {
  console.error(`Error [${result.code}]: ${result.message}`);
} else {
  console.log(result);
}

### Leveraging Result Types for functional-style error handling. Some developers prefer using a Result pattern instead of exceptions, making error handling explicit and easier to compose.

typescript
type Result<T, E> = { ok: true; value: T } | { ok: false; error: E };

function parseNumber(input: string): Result<number, string> {
  const parsed = Number(input);
  if (isNaN(parsed)) {
    return { ok: false, error: 'Invalid number format' };
  }
  return { ok: true, value: parsed };
}

function double(input: string): Result<number, string> {
  const numResult = parseNumber(input);
  if (!numResult.ok) {
    return numResult;  // Propagate error
  }
  return { ok: true, value: numResult.value * 2 };
}

const result = double('abc');
if (result.ok) {
  console.log('Double is', result.value);
} else {
  console.error('Error:', result.error);
}

### Summary. To handle complex error propagation in scalable TypeScript systems: - Use custom error classes for clear identification. - Consider an application-wide error shape. - Explore Result types for explicit error handling. - Always propagate errors to the appropriate level for correct handling. These practices improve maintainability and reliability as your project grows.