javascriptroom guide

Error Handling in TypeScript: Best Practices and Strategies

Error handling is a critical aspect of building robust, maintainable, and user-friendly applications. In TypeScript—a superset of JavaScript that adds static typing—developers gain powerful tools to catch issues early, but runtime errors (e.g., network failures, invalid user input, or unexpected data) still pose significant challenges. Unlike JavaScript, TypeScript’s type system helps identify potential errors at compile time, but it cannot eliminate runtime exceptions entirely. Poor error handling leads to cryptic bugs, unresponsive applications, and frustrated users. For example, an unhandled promise rejection might crash a Node.js server, or a generic "Something went wrong" message leaves users (and developers) in the dark about what failed. TypeScript enhances error handling by enabling type-safe error detection, explicit error typing, and better tooling for debugging. This blog explores **best practices**, **strategies**, and **advanced techniques** for error handling in TypeScript. Whether you’re building a frontend app with React, a backend service with Node.js, or a CLI tool, these guidelines will help you write code that fails gracefully, communicates clearly, and simplifies debugging.

Table of Contents

  1. Understanding Errors in TypeScript
  2. Best Practices for Error Handling in TypeScript
  3. Strategies for Effective Error Management
  4. Advanced Error Handling Techniques
  5. Common Pitfalls to Avoid
  6. Conclusion
  7. References

Understanding Errors in TypeScript

Before diving into best practices, it’s essential to understand the types of errors you’ll encounter in TypeScript and how they differ from JavaScript.

1. Built-in Error Types

TypeScript inherits JavaScript’s built-in error classes, which represent common failure scenarios:

  • Error: The base class for all errors. It includes a message property and a stack trace (in most environments).
  • SyntaxError: Thrown when parsing invalid code (e.g., JSON.parse('{ invalid }')).
  • ReferenceError: Thrown when accessing an undefined variable (e.g., console.log(undefinedVar)).
  • TypeError: Thrown when a value is not of the expected type (e.g., 'string'.toUpperCase(123)).
  • RangeError: Thrown when a value is outside an allowed range (e.g., [].splice(-1, 0)).
  • URIError: Thrown by URI-related functions like decodeURI with invalid input.

Example:

// Throws a TypeError: "toUpperCase" is not a function
const value: unknown = 123;
(value as string).toUpperCase(); 

2. Custom Error Classes

Built-in errors are generic. For application-specific failures (e.g., “user not found” or “payment failed”), create custom error classes by extending Error. This allows you to:

  • Differentiate error types in catch blocks.
  • Attach metadata (e.g., HTTP status codes, error codes).
  • Improve debugging with distinct error names.

Example: Custom Error

class UserNotFoundError extends Error {
  constructor(public userId: string) {
    super(`User with ID ${userId} not found`);
    this.name = "UserNotFoundError"; // Override the default "Error" name
    Object.setPrototypeOf(this, UserNotFoundError.prototype); // Fix prototype chain (critical for instanceof checks)
  }
}

// Usage
function fetchUser(userId: string): User {
  const user = database.getUser(userId);
  if (!user) {
    throw new UserNotFoundError(userId); // Throw custom error
  }
  return user;
}

3. Compile-Time vs. Runtime Errors

TypeScript’s biggest advantage is catching compile-time errors (e.g., type mismatches) during development. However, runtime errors (e.g., network failures, invalid JSON) still occur and require explicit handling.

Compile-Time ErrorsRuntime Errors
Caught by TypeScript’s type checker.Occur during execution (e.g., in browsers/Node.js).
Examples: Type mismatches, missing properties.Examples: Network errors, invalid user input, null references.

Example: Compile-Time vs. Runtime

// Compile-time error: Type 'number' is not assignable to type 'string'
const name: string = 123; 

// Runtime error: Thrown when API returns 404
async function fetchData() {
  const response = await fetch("/api/data");
  if (!response.ok) {
    throw new Error(`HTTP error: ${response.status}`); 
  }
}

Best Practices for Error Handling in TypeScript

1. Use Custom Error Classes for Specificity

Generic Error instances make it hard to handle errors differently. Custom errors let you target specific failures (e.g., retry on NetworkError, show a modal for ValidationError).

Example: Handling Specific Errors

class NetworkError extends Error {
  name = "NetworkError";
}

class ValidationError extends Error {
  name = "ValidationError";
}

async function submitForm(data: FormData) {
  try {
    const response = await fetch("/api/submit", { method: "POST", body: data });
    if (!response.ok) throw new NetworkError(`Server responded with ${response.status}`);
    const result = await response.json();
    if (!result.valid) throw new ValidationError(result.message);
    return result;
  } catch (error) {
    if (error instanceof NetworkError) {
      showToast("Network issue: Please try again later.");
    } else if (error instanceof ValidationError) {
      highlightFormErrors(error.message);
    } else {
      logUnexpectedError(error); // Handle unknown errors
    }
  }
}

2. Avoid any for Error Types

In TypeScript, untyped errors (e.g., catch (error: any)) bypass type safety and hide bugs. Instead, use unknown (TypeScript 4.4+) for errors, forcing you to validate the error type before using it.

Bad Practice:

try { /* ... */ } catch (error: any) { 
  console.error(error.message); // Unsafe: `error` might not have `message`
}

Good Practice:

try { /* ... */ } catch (error: unknown) { 
  if (error instanceof Error) {
    console.error(error.message); // Safe: `error` is confirmed to be an Error
  } else {
    console.error("Unknown error:", error); // Handle non-Error values (e.g., strings)
  }
}

3. Leverage Type Guards for Error Narrowing

Type guards are functions that check an error’s type and return a type predicate (e.g., error is UserNotFoundError). They let you safely access error-specific properties in catch blocks.

Example: Type Guard for Custom Errors

// Type guard to check if an error is a UserNotFoundError
function isUserNotFoundError(error: unknown): error is UserNotFoundError {
  return error instanceof UserNotFoundError;
}

// Usage in a catch block
try {
  const user = fetchUser("123");
} catch (error: unknown) {
  if (isUserNotFoundError(error)) {
    // TypeScript now knows `error` is UserNotFoundError
    console.error(`User ${error.userId} not found`); 
  }
}

4. Handle Async/Await Errors Properly

Async operations (e.g., fetch, database calls) return promises that can reject. Always wrap await in try/catch or use .catch() to avoid unhandled promise rejections (which crash Node.js apps and log warnings in browsers).

Example: Async Error Handling

// Good: Use try/catch with await
async function loadData() {
  try {
    const data = await fetch("/api/data");
    return data.json();
  } catch (error: unknown) {
    logError(error);
    return fallbackData; // Gracefully return fallback
  }
}

// Good: Use .catch() for promise chains
fetch("/api/data")
  .then(response => response.json())
  .catch(error => {
    logError(error);
    return fallbackData;
  });

// Bad: Unhandled rejection!
async function badLoadData() {
  const data = await fetch("/api/data"); // No try/catch
}

5. Validate Inputs Early

Prevent runtime errors by validating inputs at function boundaries (e.g., API request payloads, user input). Use TypeScript’s type system and runtime checks to ensure data matches expectations.

Example: Input Validation

class ValidationError extends Error {
  name = "ValidationError";
}

function createUser(userData: { name: string; email: string }) {
  // Validate required fields
  if (!userData.name) throw new ValidationError("Name is required");
  if (!userData.email?.includes("@")) throw new ValidationError("Invalid email");

  // Proceed if valid
  return db.insert(userData);
}

6. Avoid Silent Failures

Empty catch blocks or console.log-only handling hide bugs and make debugging impossible. Always log errors and take action (e.g., retry, notify the user, or shut down gracefully).

Bad Practice:

try { /* risky operation */ } catch (error) { /* do nothing */ } 

Good Practice:

try { /* risky operation */ } catch (error) {
  logErrorToService(error); // Persist error for debugging
  showUserError("Something went wrong. Our team has been notified."); // Keep user informed
}

7. Document Error Conditions

Use JSDoc or TSDoc to document which errors a function may throw. This helps other developers (and future you) handle errors correctly.

Example: Documenting Errors

/**
 * Fetches a user by ID from the database.
 * @param userId - The ID of the user to fetch.
 * @returns The user object.
 * @throws {UserNotFoundError} If the user does not exist.
 * @throws {DatabaseError} If the database connection fails.
 */
async function fetchUser(userId: string): Promise<User> { /* ... */ }

Strategies for Effective Error Management

1. Centralized Error Handling

Decentralized error handling (e.g., repeating try/catch logic everywhere) leads to inconsistency. Instead, centralize error handling with:

  • Middleware (e.g., Express.js error middleware).
  • Error boundaries (React apps).
  • Global handlers (e.g., window.onerror in browsers, process.on('uncaughtException') in Node.js).

Example: Express Error Middleware

import express, { Request, Response, NextFunction } from "express";
const app = express();

// Route that may throw errors
app.get("/api/users/:id", async (req, res, next) => {
  try {
    const user = await fetchUser(req.params.id);
    res.json(user);
  } catch (error) {
    next(error); // Pass error to middleware
  }
});

// Centralized error handler
app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
  console.error(err.stack);
  if (err instanceof UserNotFoundError) {
    return res.status(404).json({ error: err.message });
  }
  res.status(500).json({ error: "Internal server error" });
});

2. Differentiate Between Operational and Programming Errors

Not all errors are equal.区分:

  • Operational Errors: Expected failures (e.g., “user not found,” “network timeout”). Handle these gracefully (e.g., return 404).
  • Programming Errors: Bugs (e.g., undefined is not a function). These should crash the app (in Node.js) or trigger a fallback (in browsers), as they indicate unhandled flaws.

Example: Handling Operational vs. Programming Errors

process.on("uncaughtException", (error) => {
  if (isOperationalError(error)) {
    // Log and continue (e.g., retry the operation)
    logger.warn("Operational error:", error);
  } else {
    // Crash and restart (programming error; app is in an invalid state)
    logger.error("Programming error:", error);
    process.exit(1);
  }
});

// Helper to check operational errors
function isOperationalError(error: Error): boolean {
  return error instanceof UserNotFoundError || 
         error instanceof NetworkError || 
         error instanceof ValidationError;
}

3. Structured Logging for Errors

Generic logs like console.error("Error occurred") are useless for debugging. Use structured logging (e.g., JSON format) to include context like timestamps, error types, user IDs, and stack traces.

Example: Structured Logging with Winston

import winston from "winston";

const logger = winston.createLogger({
  format: winston.format.json(),
  transports: [new winston.transports.File({ filename: "errors.log" })],
});

try {
  await fetchUser("123");
} catch (error: unknown) {
  logger.error({
    message: "Failed to fetch user",
    error: error instanceof Error ? error.message : String(error),
    stack: error instanceof Error ? error.stack : undefined,
    userId: "123",
    timestamp: new Date().toISOString(),
  });
}

Advanced Error Handling Techniques

1. Using Result Types (Either Monad)

Throwing errors disrupts the normal flow of code. Instead, return a Result type to explicitly handle success/failure without exceptions. This is common in functional programming and libraries like fp-ts.

Example: Custom Result Type

// Define a Result type: either "Ok" with a value or "Err" with an error
type Result<T, E = Error> = 
  | { ok: true; value: T }