Programming Language
JavaScript
Error Handling

JavaScript Error Handling Guide

Introduction

Error handling is crucial for writing reliable JavaScript code. This guide covers everything you need to know about handling errors effectively, from basic concepts to advanced patterns and best practices.

Error Fundamentals

throw Statement

The throw statement is used to generate custom errors. It can throw any value, but it's best practice to throw Error objects.

throw new Error('Invalid input');
// or
throw new TypeError('Expected number but got string');

Error Constructor

The Error constructor creates an error object with a message. All other error types inherit from this base class.

const error = new Error('Something went wrong');
console.log(error.message); // 'Something went wrong'
console.log(error.name);    // 'Error'
console.log(error.stack);   // Stack trace

The Error constructor is the base class for all built-in error types in JavaScript. Always prefer throwing Error objects over primitive values for better error handling.

Error Handling Patterns

Error handling is crucial for writing reliable JavaScript code. This article covers how to gracefully manage errors using try, catch, and finally blocks for synchronous code, as well as how to handle asynchronous tasks with Promises using then, catch, and finally. We’ll delve into practical examples to illustrate when and why to use these constructs, helping you to ensure your applications can handle and recover from unexpected issues effectively.

Using try...catch...finally

The try...catch...finally statement is the fundamental way to handle exceptions in JavaScript. Each part serves a specific purpose:

Using try...catch

What is try...catch? The try...catch statement is used to handle exceptions that occur during the execution of code. It allows you to test a block of code (try) for errors, and handle those errors (catch) if they occur.

try {
  // Code that may throw an error
} catch (error) {
  // Code to handle the error
} finally {
  // Code that always runs, regardless of whether an error occurred
}

What is the try Block?

The try block contains code that may throw an error. By wrapping potentially problematic code in a try block, you can handle errors that occur during its execution without crashing the entire application.

What is the catch Block?

The catch block is used to handle errors that are thrown in the try block. It provides a way to respond to the error, such as logging it or displaying a user-friendly message.

What is the finally Block?

The finally block contains code that runs after the try and catch blocks, regardless of whether an error occurred. It is typically used for cleanup tasks that must happen whether or not an error occurred.

try {
  // Code that may throw an error
  riskyOperation();
} catch (error) {
  // Handle the error
  console.error('An error occurred:', error);
} finally {
  // Code that always runs
  console.log('Cleanup code runs here');
}

When to Use Each Block

  • try: Use for code that might fail

    • Network requests
    • File operations
    • Data parsing
    • Type conversions
  • catch: Use for error recovery

    • Logging errors
    • Displaying user messages
    • Implementing fallback behavior
    • Error reporting
  • finally: Use for cleanup operations

    • Closing files
    • Releasing resources
    • Resetting state
    • Logging completion

Combining try, catch, and finally

Why Combine try, catch, and finally? Combining try, catch, and finally allows you to handle errors gracefully and ensure that cleanup code runs no matter what. This is useful for operations that need to complete regardless of success or failure.

Example Scenario

Imagine you are performing a database operation and need to ensure that the connection is closed properly after the operation, regardless of whether it succeeded or failed:

async function fetchData(url) {
  try {
    let response = await fetch(url);
    if (!response.ok) {
      throw new Error('Network response was not ok');
    }
    let data = await response.json();
    console.log('Data received:', data);
  } catch (error) {
    // Handling error
    console.error('Error:', error.message);
  } finally {
    // Code that runs regardless of success or failure
    console.log('API call finished');
  }
}
 
const apiUrl = 'https://jsonplaceholder.typicode.com/posts/1';
 
console.log('API call start');
fetchData(apiUrl);

In this example:

  • try: Attempts to open a database connection and execute a query.
  • catch: Handles any errors that occur during the database operation.
  • finally: Ensures that the database connection is closed and logs a completion message.

Explanation

  • try Block: Contains the code that might throw an error. If an error occurs, the execution jumps to the catch block.
  • catch Block: Executes if an error is thrown in the try block. It allows you to handle the error gracefully.
  • finally Block: (Optional) Executes after the try and catch blocks, regardless of whether an error occurred. It's commonly used for cleanup code.

Built-in Error Types

SyntaxError

Thrown when the code contains invalid syntax.

try {
  eval('if (true) {');  // Missing closing brace
} catch (error) {
  if (error instanceof SyntaxError) {
    console.error('Syntax Error:', error.message);
  }
}

TypeError

Thrown when an operation is performed on an inappropriate type.

try {
  const num = null;
  num.toString();  // Cannot call toString() on null
} catch (error) {
  if (error instanceof TypeError) {
    console.error('Type Error:', error.message);
  }
}

ReferenceError

Thrown when referencing a variable that doesn't exist.

try {
  console.log(undefinedVariable);  // Variable not declared
} catch (error) {
  if (error instanceof ReferenceError) {
    console.error('Reference Error:', error.message);
  }
}

RangeError

Thrown when a numeric value is outside its valid range.

try {
  const arr = new Array(-1);  // Invalid array length
} catch (error) {
  if (error instanceof RangeError) {
    console.error('Range Error:', error.message);
  }
}

URIError

Thrown when using URI manipulation functions incorrectly.

try {
  decodeURIComponent('%');  // Invalid URI encoding
} catch (error) {
  if (error instanceof URIError) {
    console.error('URI Error:', error.message);
  }
}
⚠️

Always check for specific error types before falling back to generic error handling. This allows for more precise error recovery strategies.

Asynchronous Error Handling

Promise-based Error Handling

Promises provide a way to handle asynchronous operations and their potential errors:

function fetchData(url) {
  return fetch(url)
    .then(response => {
      if (!response.ok) {
        throw new Error('Network response was not ok');
      }
      return response.json();
    })
    .then(data => {
      console.log('Data received:', data);
    })
    .catch(error => {
      console.error('Error:', error.message);
    })
    .finally(() => {
      console.log('Operation completed');
    });
}

Promise Error Flow

              +-------------------------+
              |    Start Async Task     |
              +-------------------------+
                         |
                         v
              +-------------------------+
              |   Promise in Progress   |
              +-------------------------+
                         |
                         v
         +----------------------------------------+
         |            Task Outcome                |
         |          Success      Failure          |
         +----------------------------------------+
           |                    |
           v                    v
+---------------------+  +------------------------+
| Execute 'then' Block|  |   Execute 'catch' Block|
+---------------------+  +------------------------+
           |                    |
           v                    v
   +-------------------+  +------------------------+
   |Execute 'finally'  |  |   Execute 'finally'    |
   +-------------------+  +------------------------+
           |                    |
           v                    v
    +-------------------+  +-------------------+
    |        End        |  |        End        |
    +-------------------+  +-------------------+

Async/Await Error Handling

Modern async/await syntax provides a more readable way to handle asynchronous errors:

async function fetchUserData(userId) {
  try {
    const response = await fetch(`/api/users/${userId}`);
    
    if (!response.ok) {
      throw new Error(`HTTP error! status: ${response.status}`);
    }
    
    const data = await response.json();
    return data;
    
  } catch (error) {
    if (error instanceof TypeError) {
      console.error('Network error:', error.message);
    } else {
      console.error('Fetch error:', error.message);
    }
    throw error; // Re-throw for higher-level handling
  }
}
 
// Usage with error handling
async function displayUserProfile(userId) {
  try {
    const userData = await fetchUserData(userId);
    // Display user data
  } catch (error) {
    // Handle any errors from fetchUserData
    console.error('Failed to load user profile:', error.message);
  }
}

When working with async functions, remember that errors can occur both in the network request and in processing the response. Always handle both scenarios appropriately.

Custom Error Types

Creating Custom Errors

Create custom error types for application-specific errors:

class ValidationError extends Error {
  constructor(message) {
    super(message);
    this.name = 'ValidationError';
  }
}
 
function processUserData(data) {
  try {
    // Validate user input
    if (!data.name) {
      throw new ValidationError('Name is required');
    }
    if (typeof data.age !== 'number') {
      throw new TypeError('Age must be a number');
    }
    if (data.age < 0 || data.age > 100) {
      throw new RangeError('Age must be between 0 and 100');
    }
 
    // Process the data...
    return `Processed data for ${data.name}`;
 
  } catch (error) {
    if (error instanceof ValidationError) {
      console.error('Validation failed:', error.message);
    } else if (error instanceof TypeError) {
      console.error('Invalid type:', error.message);
    } else if (error instanceof RangeError) {
      console.error('Invalid range:', error.message);
    } else {
      console.error('Unknown error:', error.message);
    }
    throw error; // Re-throw the error for higher-level handling
  }
}

Real-world Example: API Error Handling

class APIError extends Error {
  constructor(message, status, code) {
    super(message);
    this.name = 'APIError';
    this.status = status;
    this.code = code;
  }
}
 
async function fetchUserProfile(userId) {
  try {
    const response = await fetch(`/api/users/${userId}`);
    
    if (!response.ok) {
      throw new APIError(
        'Failed to fetch user profile',
        response.status,
        'USER_FETCH_ERROR'
      );
    }
    
    return await response.json();
  } catch (error) {
    if (error instanceof APIError) {
      // Handle API-specific errors
      console.error(`API Error (${error.status}):`, error.message);
      // Implement retry logic or fallback
    } else if (error instanceof TypeError) {
      // Handle network errors
      console.error('Network Error:', error.message);
    } else {
      // Handle unexpected errors
      console.error('Unexpected Error:', error);
    }
    throw error;
  }
}

Best Practices

Error Handling Guidelines

  1. Use try-catch Blocks Appropriately

    • Wrap potentially risky operations
    • Handle specific errors first
    • Provide meaningful error messages
    • Only catch errors you can handle
  2. Error Object Best Practices

    • Always throw Error objects
    • Use specific error types
    • Include detailed error messages
    • Preserve the error stack trace
    • Add relevant error properties
  3. Async Error Handling

    • Always handle Promise rejections
    • Use try-catch with async/await
    • Implement proper cleanup in finally blocks
    • Consider retry mechanisms for transient failures
  4. Custom Error Types

    • Create application-specific error classes
    • Extend built-in Error types
    • Include relevant error data
    • Use meaningful error names and codes
  5. Error Recovery

    • Implement fallback behavior
    • Log errors appropriately
    • Provide user-friendly messages
    • Consider error severity
    • Implement retry mechanisms where appropriate
  6. Error Prevention

    • Validate input data
    • Check preconditions
    • Use TypeScript for type safety
    • Implement proper data validation
    • Use defensive programming techniques

Remember: Good error handling makes your application more reliable and maintainable. Always plan for things to go wrong and handle errors gracefully.

Common Error Handling Patterns

Retry Pattern

async function fetchWithRetry(url, maxRetries = 3) {
  for (let i = 0; i < maxRetries; i++) {
    try {
      const response = await fetch(url);
      if (!response.ok) throw new Error('Request failed');
      return await response.json();
    } catch (error) {
      if (i === maxRetries - 1) throw error;
      // Wait before retrying (exponential backoff)
      await new Promise(resolve => 
        setTimeout(resolve, Math.pow(2, i) * 1000)
      );
    }
  }
}

Fallback Pattern

async function fetchDataWithFallback(primaryUrl, fallbackUrl) {
  try {
    return await fetchData(primaryUrl);
  } catch (error) {
    console.warn('Primary source failed, using fallback');
    try {
      return await fetchData(fallbackUrl);
    } catch (fallbackError) {
      throw new Error('All data sources failed');
    }
  }
}

Circuit Breaker Pattern

class CircuitBreaker {
  constructor(request, options = {}) {
    this.request = request;
    this.failureThreshold = options.failureThreshold || 5;
    this.resetTimeout = options.resetTimeout || 60000;
    this.failures = 0;
    this.isOpen = false;
    this.lastFailure = null;
  }
 
  async execute(...args) {
    if (this.isOpen) {
      if (Date.now() - this.lastFailure >= this.resetTimeout) {
        this.isOpen = false;
      } else {
        throw new Error('Circuit breaker is open');
      }
    }
 
    try {
      const result = await this.request(...args);
      this.failures = 0;
      return result;
    } catch (error) {
      this.failures++;
      this.lastFailure = Date.now();
      if (this.failures >= this.failureThreshold) {
        this.isOpen = true;
      }
      throw error;
    }
  }
}

Conclusion

Effective error handling is essential for building robust JavaScript applications. By following these patterns and best practices, you can:

  • Prevent application crashes
  • Provide better user experience
  • Make debugging easier
  • Improve application reliability
  • Enable better error recovery
  • Implement proper cleanup procedures

Remember to:

  • Always use appropriate error types
  • Handle errors at the right level
  • Implement proper cleanup
  • Log errors appropriately
  • Provide meaningful error messages
  • Consider error recovery strategies