Guidelines
Error Handling

Error Handling

Handle errors according to their type

To effectively handle errors in a JavaScript application, it is important to understand the types of errors that can occur. There are two main categories of errors in JavaScript: system errors and logic errors.

System errors

System errors are errors that occur during the execution of a program due to problems with the system or environment in which the program is running. These errors are usually unexpected and may be caused by issues such as hardware failure, network connectivity problems, or data corruption.

Because system errors are often beyond the control of the programmer, the best approach is to handle them gracefully and recover from the error if possible. This may involve logging the error, alerting the user, or retrying the operation.

Logic errors

Logic errors, on the other hand, are errors that occur due to mistakes or oversight on the part of the programmer. These errors are often the result of incorrect assumptions or misunderstandings about how the code should behave, and they can be difficult to detect and fix because they are not detected by the compiler or interpreter.

It is important to handle logic errors aggressively and fail fast, rather than ignoring them or trying to recover from them. This will help to identify and fix them as soon as possible to prevent them from causing unexpected errors or issues in your code.

Use try-catch to handle synchronous errors

A try-catch block allows you to wrap code that may throw an error in a try block, and provide a separate catch block to handle the error if it occurs. This allows you to handle the error gracefully, rather than letting it propagate and potentially crash the program.

try {
  const data = JSON.parse("invalid JSON");
} catch (error) {
  console.error(error);
}

Use async-await or Promise to handle asynchronous errors

When working with asynchronous code, you can use the async-await syntax or the catch method from the Promise to handle errors.

  • The async-await syntax allows you to write asynchronous code in a synchronous-style and let you handles any errors that are thrown using a try-catch block.
  • The catch method can be used to handle errors on a Promise, and it works similarly to the catch block in a try-catch block.
const fetchData = async () => {
  try {
    const response = await fetch("/invalid-url");
    const data = await response.json();
  } catch (error) {
    console.error(error);
  }
};
 
fetchData();

Use finally to clean up resources

The finally allows you to execute code regardless of whether an error occurred or not. This can be useful for cleaning up resources, such as closing database connections or releasing locks, that need to be released regardless of whether an error occurred or not.

const getDataFromDatabase = () => {
  const connection = openDatabaseConnection();
 
  try {
    const data = connection.query("SELECT * FROM users WHERE id = ?", [123]);
  } catch (error) {
    console.error(error);
  } finally {
    console.log("Closing database connection...");
    connection.close();
  }
};
 
getDataFromDatabase();

Use descriptive error messages

Use descriptive error messages that clearly describe the nature of the error and how it can be resolved. This can make it easier to debug problems and understand what went wrong.

// 🚫 Bad
throw new Error("Error");
 
// ✅ Good
throw new Error("Username is required");

Don't throw string errors

Avoid throwing string errors, as they can be hard to understand and debug. Instead, throw an error object that provides more context about the error, such as an instance of the built-in Error class or a custom error type.

// 🚫 Bad
throw "Username is required";
 
// ✅ Good
throw new Error("Username is required");

Use domain-specific error types

Define custom error classes or types that are specific to your application or domain. This will allow you to distinguish between different types of errors and handle them appropriately.

class HTTPError extends Error {
  constructor(statusCode, message) {
    super(message);
    this.name = "HTTPError";
    this.statusCode = statusCode;
  }
}
 
const getData = async () => {
  try {
    const response = await fetch("/data");
    if (!response.ok) {
      throw new HTTPError(response.status, response.statusText);
    }
    const data = await response.json();
    console.log(data);
  } catch (error) {
    if (error instanceof HTTPError) {
      console.error(`${error.name}: ${error.statusCode} - ${error.message}`);
    } else {
      console.error(error);
    }
  }
};
 
getData();

Don't ignore errors

Don't ignore errors or leave them unhandled, as this can lead to hidden bugs and unexpected behavior. Instead, always make sure to handle errors appropriately, either by logging them or providing a way to recover from them.

// 🚫 Bad
try {
  const data = JSON.parse("invalid JSON");
} catch (error) {
  // Do nothing with the error
}
 
// ✅ Good
try {
  const data = JSON.parse("invalid JSON");
} catch (error) {
  // Handle the error
  console.error(error);
}

Don't throw errors in catch

Avoid throwing errors in catch, as this can make it difficult to debug problems and understand what went wrong. Instead, handle errors in catch by logging them or providing a way to recover from them.

try {
  if (!user.username) {
    throw new Error("Username is required");
  }
} catch (error) {
  console.error(error.message);
  throw new Error("Error occurred while handling error"); // 🚫 Bad
}

Don't throw errors on a different call stack

If you are creating a Promise or using a callback-based function, you may be using a setTimeout, setInterval, or a callback function that is called when an operation is complete.

These callbacks will be executed on a different call stack, which means that any errors thrown within them will not be caught by your code, and will instead propagate to a different call stack. This can make it more difficult to handle and resolve errors, and can make your code less robust.

To prevent errors from being thrown on a different call stack and to facilitate efficient error handling, you can return them using the reject function on a Promise or the callback function on a callback-based function.

Promise

On a Promise, return the error using the reject function.

const fetchData = () => {
  return new Promise((resolve, reject) => {
    fetch("https://api.example.com/users")
      .then((res) => res.json())
      .then((data) => {
        if (!data) {
          setTimeout(() => {
            reject(new Error("No data returned")); // ✅ Good
          }, 0);
        } else {
          resolve(data);
        }
      })
      .catch((error) => reject(error));
  });
};
 
const getUser = async () => {
  try {
    await fetchData();
  } catch (error) {
    console.log(error); // Catch the error
  }
};

Callback-based function

On a callback-based function, return the error using the callback function.

const fs = require("fs");
 
const readFile = (filename, callback) => {
  fs.readFile(filename, "utf8", (err, data) => {
    if (err) {
      callback(err); // ✅ Good
    } else {
      callback(null, data);
    }
  });
};

Use a centralized error handler

A centralized error handler is a function or piece of code that is responsible for handling errors that occur in an application.

It can improve the error handling process by providing a single location to log or report errors, improving the user experience by providing a consistent way of displaying error messages, and making it easier to maintain the code by centralizing error handling logic.

const handleError = async (error) => {
  await logger.error(error);
  await sendErrorToSentry(error);
 
  if (isErrorCritical(error)) {
    await sendErrorToAdmin(error);
  }
};

Catch all unhandled errors

Unhandled errors are errors that are not caught by other error handling mechanisms, such as try-catch blocks or Promises. If an unhandled error occurs, it can cause the application to crash or behave unexpectedly, which can lead to a poor user experience.

To catch unhandled rejections on the client side and on the server side, you can use the unhandledrejection and unhandledRejection events respectively. This event is emitted whenever a Promise is rejected and there is no catch handler to handle the rejection.

// Client side
window.addEventListener("unhandledrejection", (event) => {
  console.error(event.reason);
});
 
// Server side
process.on("unhandledRejection", (reason, promise) => {
  console.error(reason);
});

To catch uncaught exceptions on the client side and on the server side, you can use the error and uncaughtException events respectively. This event is emitted whenever an uncaught exception occurs, which means that an exception was thrown and there was no try-catch block to handle it.

// Client side
window.addEventListener("error", (event) => {
  console.error(event.error);
});
 
// Server side
process.on("uncaughtException", (error) => {
  console.error(error);
});

Test your error handling

Make sure to test your error handling code to ensure that it works as expected and provides the right feedback to users. This can help you catch and fix problems early on and improve the reliability of your application.

Last updated on