Error Handling in JavaScript with try, catch, and finally

Abstract image used to represent JS Error Handling with try, catch, and finally
Image by Maël BALLAND.

Writing a try/catch block is straightforward. The harder part is deciding which layer can actually recover, what information the error must retain, and which failures should still be allowed to surface. A catch block that merely hides the exception usually leaves the application in a more confusing state than the original error did.


How try, catch, and finally Work

At their simplest, the try, catch, and finally statements give us control over errors that occur within a block of code:

  • try: contains code that might throw an error.
  • catch: catches and handles errors thrown from the try block.
  • finally: runs code regardless of whether an error occurred.

Example of Basic Error Handling

Here's a fairly straightforward example:

try {
  const data = JSON.parse('{invalidJson}');
  console.log('Parsed data:', data);
} catch (error) {
  console.error('Error parsing JSON:', error.message);
} finally {
  console.log('Cleaning up...');
}

If an error occurs in the try block, JavaScript immediately moves to the catch block. Regardless of an error, the finally block runs afterwards, which is useful for tasks like closing resources or resetting states.


Advanced Usage: Custom Error Types

For complex applications, custom error types can help handle errors more effectively.

Creating Custom Errors

By extending JavaScript's builtin Error class, we can create custom error types, like this:

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

const validateUser = (user: { name?: string }) => {
  if (!user.name) {
    throw new ValidationError('Missing user name');
  }
};

Using a custom error:

try {
  validateUser({});
} catch (error) {
  if (error instanceof ValidationError) {
    console.log('Validation failed:', error.message);
  } else {
    throw error;  // rethrow unknown errors
  }
}

In this way, we can use custom errors to clearly communicate what went wrong, which will help us and our colleagues with code readability and maintainability.


Handling Errors Asynchronously

When working with asynchronous code, handling errors properly becomes even more crucial. JavaScript's async/await syntax integrates seamlessly with try, catch, and finally:

Example of async/await Error Handling

const fetchUserData = async (userId: number) => {
  try {
    const response = await fetch(`/api/users/${userId}`);
    if (!response.ok) {
      throw new Error(`Request failed with status ${response.status}`);
    }
    const user = await response.json();
    return user;
  } catch (error) {
    throw new Error(`Could not fetch user ${userId}`, { cause: error });
  } finally {
    console.log('Finished user request');
  }
};

The catch block adds the user ID as context and rethrows with the original error as its cause. That leaves the promise rejected, so the caller can show a failure state or decide whether to retry. Logging alone would not make the request successful. The finally block runs after either outcome.


Common Pitfalls in Error Handling

Swallowing Errors

It's really important that, as developers, we avoid ignoring or "swallowing" errors, otherwise, when something does go wrong, we may have no way of working out the root cause. Always handle or explicitly log them, as unhandled errors become difficult to debug:

try {
  performRiskyAction();
} catch (error) {
  // Don't leave this empty!
  console.error('An error occurred:', error);
}

Overusing try/catch

The flip side of this is, we should use error handling thoughtfully. If we wrap every function call in try and catch blocks, we quickly end up obscuring the real source of errors. Place errorhandling code strategically, especially near code likely to fail or at application boundaries (e.g., network requests).


Testing Error Handling

Reliable applications test error handling thoroughly. Here's how you might test that a function throws an error when expected, using Jest:

test('throws ValidationError if user name is missing', () => {
  expect(() => validateUser({})).toThrow(ValidationError);
});

Including this type of test in your codebase ensures that errors behave predictably and help catch bugs before they reach your users.


Wrapping Up

Key Takeaways

  • try, catch, and finally provide structured control over errors in JavaScript.
  • Custom errors improve clarity and control for more complex error handling.
  • Always explicitly handle errors to maintain code readability and debug effectively.
  • async/await integrates naturally with structured error handling.
  • Avoid ignoring errors silently; always log or handle them appropriately.

Catch an error where the code can add context, recover, or present a useful failure state. Preserve the original cause, use custom error types only when callers can act on the distinction, and test the failure path as deliberately as the successful one. If a catch block can do none of those things, it may be the wrong place to catch the error.


Want to find out more?

If you need senior handson support with a complex React or Next.js platform, migration, performance issue, or technical SEO problem, send me the context and I'll tell you where I can help.