TypeScript Exception Handling: Try, Catch, and Best Practices

While developing a TypeScript-based web application, you created a feature that fetches user data from an API. Sometimes, the API is down or returns an error. To handle such cases without crashing the app, you used a try-catch block inside an async function.

This way, if the API call fails, you can display an error message, such as “Something went wrong, please try again later,” instead of a blank screen or a broken app.

In this article, I’ll cover several effective ways of exception handling in TypeScript (including basic try/catch, custom error types, and async error handling). So let’s dive in!

Understanding Exceptions in TypeScript

TypeScript, like JavaScript, allows you to throw and catch exceptions using the familiar try-catch pattern. However, TypeScript introduces powerful type-checking capabilities that enhance the robustness of exception handling.

Let’s start with the basics and then explore more advanced patterns.

Basic Exception Throwing in TypeScript

The simplest way to throw an exception in TypeScript is to use the throw keyword:

function validateAge(age: number): void {
  if (age < 0) {
    throw new Error("Age cannot be negative");
  }

  if (age > 120) {
    throw new Error("Age seems unrealistically high");
  }

  console.log("Age is valid:", age);
}

try {
  validateAge(150);
} catch (error) {
  if (error instanceof Error) {
    console.error(error.message);
  } else {
    console.error(error);
  }
}

Here, I’m validating a user’s age for a US healthcare application. If the age is outside reasonable bounds, an exception is thrown with a descriptive message.

Basic Exception Throwing in TypeScript

Custom Error Types in TypeScript

While using the built-in Error class works fine, I’ve found that creating custom error types makes exception handling much more maintainable, especially in larger applications:

class ValidationError extends Error {
  constructor(message: string) {
    super(message);
    this.name = "ValidationError";

    // Necessary for proper inheritance in TypeScript
    Object.setPrototypeOf(this, ValidationError.prototype);
  }
}

class DatabaseError extends Error {
  constructor(message: string, public readonly code: number) {
    super(message);
    this.name = "DatabaseError";

    Object.setPrototypeOf(this, DatabaseError.prototype);
  }
}

function processUserData(userId: string): void {
  if (!userId) {
    throw new ValidationError("User ID is required");
  }

  // Simulate database error
  if (userId === "invalid") {
    throw new DatabaseError("Failed to fetch user data", 404);
  }

  console.log("Processing data for user:", userId);
}

// Test call inside try-catch
try {
  processUserData("invalid"); // Change this to "", "invalid", or a valid ID to test
} catch (error) {
  if (error instanceof ValidationError) {
    console.error("Validation Error:", error.message);
  } else if (error instanceof DatabaseError) {
    console.error(`Database Error (${error.code}):`, error.message);
  } else if (error instanceof Error) {
    console.error("General Error:", error.message);
  } else {
    console.error("Unknown error:", error);
  }
}

By creating specific error types, I can include additional contextual information (like error codes) and handle different error categories separately.

Custom Error Types in TypeScript code

Handling Different Types of Exceptions in TypeScript

One of TypeScript’s strengths is its ability to use type checking when catching exceptions:

// Define ValidationError class
class ValidationError extends Error {
  constructor(message: string) {
    super(message);
    this.name = "ValidationError";
    Object.setPrototypeOf(this, ValidationError.prototype);
  }
}

// Define DatabaseError class
class DatabaseError extends Error {
  constructor(message: string, public readonly code: number) {
    super(message);
    this.name = "DatabaseError";
    Object.setPrototypeOf(this, DatabaseError.prototype);
  }
}

// Define processUserData function
function processUserData(userId: string): void {
  if (!userId) {
    throw new ValidationError("User ID is required");
  }
  if (userId === "invalid") {
    throw new DatabaseError("Failed to fetch user data", 404);
  }
  console.log("Processing data for user:", userId);
}

// Now safely call with try-catch
try {
  processUserData("invalid"); // test with "", "invalid", or "user123"
} catch (error) {
  if (error instanceof ValidationError) {
    console.log("Invalid input:", error.message);
  } else if (error instanceof DatabaseError) {
    console.log(`Database error (${error.code}):`, error.message);
  } else if (error instanceof Error) {
    console.log("General error:", error.message);
  } else {
    console.log("Unknown error:", error);
  }
}

This approach enables you to handle different error categories appropriately, which is critical for real-world applications where various things can go wrong.

Handling Different Types of Exceptions in TypeScript

Async Exception Handling in TypeScript

Most modern TypeScript applications use asynchronous code. Here’s how to handle exceptions in async functions:

// Custom error classes
class ValidationError extends Error {
  constructor(message: string) {
    super(message);
    this.name = "ValidationError";
    Object.setPrototypeOf(this, ValidationError.prototype);
  }
}

class DatabaseError extends Error {
  constructor(message: string, public readonly code: number) {
    super(message);
    this.name = "DatabaseError";
    Object.setPrototypeOf(this, DatabaseError.prototype);
  }
}

// Async function to simulate fetching user profile
async function fetchUserProfile(userId: string): Promise<any> {
  if (!userId) {
    throw new ValidationError("User ID is required");
  }

  try {
    // Simulate API call and possible error
    if (userId === "invalid") {
      throw new DatabaseError("User not found", 404);
    }

    // Simulate successful API response
    return { id: userId, name: "John Doe" };
  } catch (error) {
    if (error instanceof Error) {
      console.log("Error in fetchUserProfile:", error.message);
    } else {
      console.log("Unknown error in fetchUserProfile:", error);
    }
    throw error; // rethrow for caller to handle
  }
}

// Usage function to display user profile
async function displayUserProfile(userId: string): Promise<void> {
  try {
    const profile = await fetchUserProfile(userId);
    console.log("User profile:", profile);
  } catch (error) {
    if (error instanceof ValidationError) {
      console.log("Invalid input:", error.message);
    } else if (error instanceof DatabaseError) {
      console.log(`Database error (${error.code}):`, error.message);
    } else if (error instanceof Error) {
      console.log("General error:", error.message);
    } else {
      console.log("Unknown error:", error);
    }
  }
}

// Test calls
displayUserProfile(""); // Test with missing userId
displayUserProfile("invalid"); // Test with invalid userId
displayUserProfile("user123"); // Test with valid userId

In this pattern, I’m re-throwing the exception from the inner function, allowing the caller to decide how to handle different error types.

TypeScript Exception Handling

Best Practices for Exception Handling in TypeScript

After years of working with TypeScript, I’ve developed these best practices:

1. Be Specific About What You Throw

Rather than throwing generic errors, create specific error types that relate to your domain:

// Instead of this:
throw new Error("Invalid tax ID");

// Do this:
throw new TaxValidationError("Tax ID format is invalid for US resident");

2. Only Catch What You Can Handle

Don’t catch exceptions unless you can meaningfully handle them:

try {
  processPayment(order);
} catch (error) {
  if (error instanceof PaymentDeclinedError) {
    notifyUser("Your payment was declined. Please try another card.");
  } else {
    // Re-throw errors we don't know how to handle
    throw error;
  }
}

3. Include Contextual Information

Make your error messages informative:

function validateZipCode(zipCode: string): void {
  if (!/^\d{5}(-\d{4})?$/.test(zipCode)) {
    throw new ValidationError(
      `"${zipCode}" is not a valid US ZIP code. Format should be 12345 or 12345-6789.`
    );
  }
}

Advanced Pattern: Result Type in TypeScript

For critical operations where you want to avoid exceptions entirely, consider using a Result type pattern:

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

function divideNumbers(a: number, b: number): Result<number> {
  if (b === 0) {
    return {
      success: false,
      error: new Error("Cannot divide by zero")
    };
  }

  return {
    success: true,
    value: a / b
  };
}

// Usage
const result = divideNumbers(10, 2);
if (result.success) {
  console.log("Result:", result.value);
} else {
  console.log("Error:", result.error.message);
}

This pattern is particularly useful in functional programming approaches and when working with complex operations where multiple errors might occur.

Advanced Pattern Result Type in TypeScript

Proper exception handling in TypeScript is essential for building robust applications. By utilizing custom error types, handling exceptions effectively, and adhering to best practices, you can develop more reliable and maintainable code.

Conclusion

I hope you found this guide helpful. Both methods work great – the try/catch approach is ideal for localized error handling, while custom error types provide better organization for larger applications.

Global error handling ensures that no exception goes unnoticed, and React error boundaries provide elegant UI fallbacks.

Other TypeScript articles you may also like:

51 Python Programs

51 PYTHON PROGRAMS PDF FREE

Download a FREE PDF (112 Pages) Containing 51 Useful Python Programs.

pyython developer roadmap

Aspiring to be a Python developer?

Download a FREE PDF on how to become a Python developer.

Let’s be friends

Be the first to know about sales and special discounts.