Skip to content

Style Guides ‐ Errors

Jonathan Sharpe edited this page Sep 5, 2024 · 8 revisions

📋 Description

The try...catch statement is comprised of a try block and either a catch block, a finally block, or both. The code in the try block is executed first, and if it throws an exception, the code in the catch block will be executed. The code in the finally block will always be executed before control flow exits the entire construct.

Source: MDN

try {
  /* work happens here */
} catch (err) {
     // ^---^ "catch binding" optional since ES2019/Node 10
  /* deal with the error */
} finally {
  /* clean up whether or not there was an error */
}

✅ Do

Throw Error objects

Although anything can be thrown, e.g. throw "oh no!" is legal JS, throwing a proper Error object means the full context can be retained and a proper traceback generated.

Don't:

throw "something went wrong";

Do:

throw new Error("something went wrong");

🤔 Consider

Extending Error

Sometimes we want different things to happen depending on what error occurred. One option for this is creating specific subclasses of Error to represent these states.

Do:

// define Error subclass
class MissingResource extends Error {
  constructor(id) {
    super(`Resource not found: ${id}`);
  }
}

// throw the subclass as needed
throw new MissingResource(resourceId);

// handle the subclass explicitly
try {
  // do some stuff
} catch (err) {
  if (err instanceof MissingResource) {
    // handle this specific kind of error
  }
  // handle any other errors
}

Example of this pattern in use in the Tech Products Demo app here.

Error codes

Another option for discriminating different kinds of error, as used throughout Node.js itself, is to have a specific code property intended for use in code (whereas the message property is intended to be human-readable).

Do:

// defined subclass for errors with codes
class CodedError extends Error {
  constructor(code, message) {
    super(message);
    this.code = code;
  }
}

// throw the subclass as needed
throw new CodedError("RESOURCE_MISSING", `Resource not found: ${resourceId}`);

// handle the coded case explicitly
try {
  // do some stuff
} catch (err) {
  if (err.code === "RESOURCE_MISSING") {
    // handle this specific kind of error
  }
  // handle any other errors
}

Logging errors we can't handle

We don't want to leak details of internal issues to end-users of our APIs, but it's still useful for devs to be able to get more data when something goes wrong in production.

It's common in Express APIs to have a catch-all error handling in the controller, to ensure we send a sensible response to the request in all cases. But we also need to make sure that we'll have enough information when things go wrong, by reviewing the actual logs from the application. That means logging the error before responding, so the details show up in e.g. Papertrail and/or the Kubernetes pod logs.

Don't:

export const handleRequest = (req, res) => {
  try {
    // ...
  } catch {
    res.status(400).send({ success: false, error: "Something went wrong!" });
  }
};

Do:

export const handleRequest = (req, res) => {
  try {
    // ...
  } catch (err) {
    console.error(err);  // or use a proper logger, if available
    res.status(400).send({ success: false, error: "Something went wrong!" });
  }
};

or:

export const handleRequest = (req, res, next) => {
  try {
    // ...
  } catch (err) {
    if (/* we can respond here */) {
      // ...
    } else {
      next(err);  // let other middleware handle it
    }
  }
};

❌ Don't

Catch errors we can't handle

Sometimes having a try construct at all is unnecessary. Remember that the caller can handle the errors.

A clear sign of an unnecessary try is that the only thing in catch is re-throwing the error. In such cases, the try can be removed entirely (or the catch block can be removed, if finally is being used) - it makes it clearer that the code in question will not be attempting to handle any errors and they'll propagate up to the caller.

Don't:

try {
  // do some things
} catch (err) {
  throw err;  // if this is the _only_ thing here, it's pointless
}

Do:

// do some things

or:

try {
  // do some things
} catch (err) {
  // do some things with `err`
  throw err;  // optionally re-throw for any case we can't handle
}

throw new Error(err)

Sometimes we can handle some errors, but want to re-throw in other cases. If this is done incorrectly, though, the context of the original problem is lost.

The first argument to the Error constructor is the message, which should be a string. If an error object is passed instead, it is stringified (e.g. new Error("oh no!") -> "Error: oh no!") and all of the context is lost. Instead, if more context is needed, give a proper message and use the second argument to pass a cause.

Don't:

throw new Error(err);

Do:

throw err;  // re-throw with original context

or:

throw new Error("an additional message", { cause: err });  // add more context

or:

throw new Error("a separate message");  // explicitly drop original context
throw new Error(err.message);  // drop original context but keep message

📚 Resources

Clone this wiki locally