Skip to main content

Command Palette

Search for a command to run...

Why JavaScript Error Handling Is the Most Usefull Skill for Developers

Updated
13 min readView as Markdown

Most of us learn JavaScript syntax first. Variables, loops, functions, async/await. Error handling comes later, if at all. We treat it like an afterthought, something we add when things break. That approach works fine until you are dealing with real users, real money, and real consequences.

This post is about making error handling a first-class skill. We will cover what errors are in JavaScript, how try and catch work, why finally exists, how to throw custom errors, and why all of this matters for building reliable applications. By the end, you should be able to write code that fails gracefully instead of falling apart.


What Errors Are in JavaScript

An error in JavaScript is an object that represents something going wrong during execution. When the engine encounters a problem it cannot resolve, it creates an error object and interrupts the normal flow of the program. This interruption is called throwing an error.

There are several built-in error types, and understanding them helps you diagnose problems faster.

Error is the base constructor. All other error types inherit from it. You will rarely throw a generic Error yourself, but it is the foundation everything else builds on.

TypeError happens when you try to do something impossible with a value. Calling a non-function as a function, reading a property from null or undefined, or passing the wrong type to a built-in method. This is probably the error you see most often in day-to-day development.

ReferenceError occurs when you try to use a variable that does not exist in the current scope. Misspelling a variable name, using a variable before it is declared in a temporal dead zone, or accessing something that was never defined. These usually mean you have a scoping or timing issue.

SyntaxError is thrown when the engine cannot parse your code. Missing brackets, invalid JSON, or malformed regular expressions. Unlike runtime errors, these happen before any code executes. You cannot catch a SyntaxError with try and catch in the same file because the script never runs.

RangeError appears when a value is not in the allowed range. Calling an array constructor with a negative length, or passing an invalid radix to Number.prototype.toString. These are less common but important to recognize.

URIError is thrown by encodeURI or decodeURI when they receive malformed URIs. You might never see this one unless you are working with user-provided URLs.

There are also modern additions like AggregateError, which wraps multiple errors into one. This is useful when you are dealing with multiple promises that fail simultaneously, such as in Promise.any.

The key thing to remember is that all these errors are just objects. They have a name property, a message property, and in modern environments, a stack property that traces where the error originated. Because they are objects, you can inspect them, log them, and extend them with your own custom types.


Using Try and Catch Blocks

The try and catch statement is the primary mechanism for handling runtime errors in JavaScript. It lets you say, "I know this code might fail, and I want to handle that failure instead of letting it crash the program."

Here is the basic structure:

try {
  const result = riskyOperation();
  console.log(result);
} catch (error) {
  console.error('Something went wrong:', error.message);
}

The engine executes the code inside the try block first. If everything works, the catch block is skipped entirely. If an error is thrown anywhere inside try, execution immediately jumps to the catch block. The error object is passed as an argument, and you can decide what to do with it.

This pattern is essential when dealing with external data. A JSON API might return malformed data. A DOM element might not exist yet. A network request might time out. Without try and catch, any of these failures would bubble up and potentially crash your application.

Here is a realistic example with JSON parsing:

function parseUserData(jsonString) {
  try {
    const data = JSON.parse(jsonString);
    return data;
  } catch (error) {
    console.error('Failed to parse user data:', error.message);
    return null;
  }
}

const valid = '{"name": "Alice", "age": 30}';
const invalid = '{name: Alice}';

console.log(parseUserData(valid));   // { name: 'Alice', age: 30 }
console.log(parseUserData(invalid)); // null

Notice how the function returns null instead of propagating the error. This is graceful failure. The caller gets a predictable result and can decide how to proceed. Maybe it shows a default user profile. Maybe it retries the request. The important thing is that the program does not crash.

You can also use catch to recover from errors in ways that keep the application running:

function getConfigValue(key) {
  try {
    const config = JSON.parse(localStorage.getItem('appConfig'));
    return config[key];
  } catch (error) {
    console.warn('Config not found or invalid, using default');
    return getDefaultValue(key);
  }
}

If localStorage is empty, corrupted, or contains invalid JSON, the function falls back to a default value. The user never sees an error message. The feature degrades gracefully.

One detail that trips people up: catch only catches errors thrown inside the try block. If you have asynchronous code, a callback that throws an error after the try block has finished will not be caught. This is why modern JavaScript uses async and await with try and catch, which we will cover later.


The Finally Block

The finally block executes regardless of whether an error was thrown. It runs after try if everything succeeded, and after catch if an error was handled. This makes it perfect for cleanup code that must run no matter what.

function readFileContents(filePath) {
  const file = openFile(filePath);
  
  try {
    const contents = file.read();
    return contents;
  } catch (error) {
    console.error('Error reading file:', error.message);
    return null;
  } finally {
    file.close();
    console.log('File closed');
  }
}

In this example, file.close() runs whether file.read() succeeds or fails. Without finally, you would need to duplicate the cleanup code in both the try and catch blocks. That is error-prone and harder to maintain.

finally is especially useful when dealing with resources that must be released. Database connections, file handles, network sockets, and DOM event listeners all fall into this category. If you acquire a resource in try, release it in finally.

Here is another pattern you will see in real code:

let connection;
try {
  connection = createDatabaseConnection();
  const results = connection.query('SELECT * FROM users');
  return results;
} catch (error) {
  throw new DatabaseError('Query failed', { cause: error });
} finally {
  if (connection) {
    connection.release();
  }
}

Even if we re-throw the error after wrapping it in a custom type, finally still runs. The connection gets released. No resource leaks.

A subtle point: if both catch and finally contain return statements, the finally return wins. This is rarely what you want, so avoid returning from finally unless you have a very specific reason.


Throwing Custom Errors

Built-in error types cover generic failures, but they do not always communicate enough context. When you are building a library or a complex application, you often want errors that describe exactly what went wrong in domain-specific terms.

JavaScript lets you create custom errors by extending the built-in Error class.

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

class NetworkError extends Error {
  constructor(url, statusCode) {
    super(`Request to \({url} failed with status \){statusCode}`);
    this.name = 'NetworkError';
    this.statusCode = statusCode;
  }
}

Custom errors let you distinguish between different failure modes programmatically. Instead of checking error messages, which are brittle and prone to change, you can check the error type:

try {
  await submitForm(data);
} catch (error) {
  if (error instanceof ValidationError) {
    showFieldError(error.field, error.message);
  } else if (error instanceof NetworkError) {
    showToast('Network issue. Please try again.');
  } else {
    showToast('An unexpected error occurred.');
    reportToTracker(error);
  }
}

This is much cleaner than parsing strings. It also makes your error handling more robust against refactoring. If you change the wording of an error message, your handling logic does not break.

Here is a practical example from a user registration flow:

function validatePassword(password) {
  if (password.length < 8) {
    throw new ValidationError(
      'password',
      'Password must be at least 8 characters'
    );
  }
  if (!/[A-Z]/.test(password)) {
    throw new ValidationError(
      'password',
      'Password must contain an uppercase letter'
    );
  }
}

try {
  validatePassword('weak');
} catch (error) {
  if (error instanceof ValidationError) {
    console.log(`\({error.field}: \){error.message}`);
    // password: Password must be at least 8 characters
  }
}

By throwing specific errors at the point of failure, you make the calling code simpler. It does not need to know the validation rules. It just needs to know how to handle a ValidationError.

You can also attach extra context to errors for debugging. Modern JavaScript supports the cause property, which lets you chain errors:

class AppError extends Error {
  constructor(message, options = {}) {
    super(message, options);
    this.name = 'AppError';
    this.timestamp = new Date().toISOString();
  }
}

try {
  JSON.parse('{invalid}');
} catch (parseError) {
  throw new AppError('Failed to load configuration', { cause: parseError });
}

Now when you log the error, you can see both the high-level context and the original root cause. This is invaluable for debugging complex applications where errors bubble through multiple layers.


Why Error Handling Matters

Error handling is not just about preventing crashes. It is about building trust with your users and maintaining your sanity as a developer.

When an application crashes, users lose work. They might have filled out a long form, uploaded files, or configured settings. A crash wipes that away. Even if the data is recoverable, the experience is frustrating. Users remember frustration more than they remember smooth sailing.

Graceful failure is the alternative. Instead of crashing, your application shows a meaningful message, preserves state, and offers a path forward. Maybe it retries the request automatically. Maybe it saves the draft to local storage. Maybe it degrades to a simpler feature. The user might notice something went wrong, but they do not lose confidence in the application.

From a debugging perspective, good error handling makes your life easier. When you catch errors at the right level and log them with context, you can trace the root cause without attaching a debugger. When you throw custom errors with descriptive messages, you know exactly which component failed and why.

Error handling also forces you to think about edge cases. What happens if this API returns an empty array? What happens if this user input contains special characters? What happens if the network drops mid-request? These are the questions that separate robust code from fragile code.

In team environments, consistent error handling patterns make code reviews faster and onboarding easier. When everyone uses the same custom error types and the same logging conventions, the codebase becomes self-documenting. New developers can see how errors are handled and follow the same patterns.


Async Error Handling

Modern JavaScript relies heavily on promises and async functions. Error handling works slightly differently in asynchronous code because the error might occur after the current call stack has finished.

With promises, you use .catch():

fetch('/api/user')
  .then(response => response.json())
  .then(data => renderProfile(data))
  .catch(error => {
    console.error('Failed to load user:', error);
    showErrorPage('profile-unavailable');
  });

With async and await, you use try and catch just like synchronous code:

async function loadUserProfile() {
  try {
    const response = await fetch('/api/user');
    if (!response.ok) {
      throw new NetworkError('/api/user', response.status);
    }
    const data = await response.json();
    return data;
  } catch (error) {
    console.error('Profile load failed:', error.message);
    return null;
  }
}

The try and catch block here catches both network failures and JSON parsing errors. It also catches the NetworkError we throw manually for non-OK responses. This is cleaner than chaining .then() and .catch() for complex flows.

One pattern I use frequently is wrapping async operations in a helper that standardizes error handling:

async function safeAsync(operation, fallback = null) {
  try {
    return await operation();
  } catch (error) {
    console.error('Operation failed:', error);
    return fallback;
  }
}

const user = await safeAsync(() => fetchUser(id), { name: 'Guest' });

This keeps the calling code clean while ensuring no unhandled promise rejections slip through.


Diagram: Error Handling Flow

Here is how the engine processes a try, catch, and finally block:

The flow is straightforward. If no error occurs, the try block runs to completion, then finally runs. If an error occurs, execution jumps immediately to catch, then finally runs. In both cases, finally executes.


Diagram: Try → Catch → Finally Execution Order

Here is a more detailed view showing what happens with return statements and re-thrown errors:

This diagram shows why returning from finally can be dangerous. It overrides any return value from try or catch. It also shows that a re-thrown error still triggers finally before propagating upward.


Putting It All Together

Let us look at a complete example that uses everything we have covered. Imagine a function that fetches user data, validates it, and caches it in local storage:

class CacheError extends Error {
  constructor(operation, originalError) {
    super(`Cache ${operation} failed`);
    this.name = 'CacheError';
    this.cause = originalError;
  }
}

class ValidationError extends Error {
  constructor(field, reason) {
    super(`Invalid \({field}: \){reason}`);
    this.name = 'ValidationError';
    this.field = field;
  }
}

async function fetchAndCacheUser(userId) {
  let response;
  
  try {
    response = await fetch(`/api/users/${userId}`);
  } catch (networkError) {
    throw new NetworkError(`/api/users/${userId}`, 0);
  }

  if (!response.ok) {
    throw new NetworkError(`/api/users/${userId}`, response.status);
  }

  let userData;
  try {
    userData = await response.json();
  } catch (parseError) {
    throw new AppError('Invalid JSON from server', { cause: parseError });
  }

  if (!userData.name || typeof userData.name !== 'string') {
    throw new ValidationError('name', 'Must be a non-empty string');
  }

  try {
    localStorage.setItem(`user_${userId}`, JSON.stringify(userData));
  } catch (storageError) {
    console.warn('Failed to cache user data:', storageError.message);
  }

  return userData;
}

// Usage
try {
  const user = await fetchAndCacheUser(123);
  renderProfile(user);
} catch (error) {
  if (error instanceof ValidationError) {
    showInputError(error.field, error.message);
  } else if (error instanceof NetworkError) {
    showOfflineMessage();
  } else {
    showGenericError();
    logToService(error);
  }
}

This function handles multiple failure points. Network errors get wrapped in a custom type. JSON parsing errors preserve their root cause. Validation errors are specific and actionable. Storage failures are logged but not fatal. The calling code can distinguish between error types and respond appropriately.

That is what professional error handling looks like. Not just preventing crashes, but creating a system where failures are visible, traceable, and recoverable.


Final Thoughts

Error handling is not glamorous. It does not get demoed in product meetings. But it is the difference between an application that users trust and one that they abandon.

Start by understanding the built-in error types. Learn to use try, catch, and finally correctly. Build custom errors that communicate domain-specific failures. Handle async errors with the same care you handle synchronous ones. And always think about what the user experiences when something goes wrong.

The best code is not code that never fails. It is code that fails gracefully, tells you exactly what happened, and lets you fix it without losing sleep.

More from this blog