Skip to content
aviral gupta

// I4.2 · ~34 min · Intermediate

Custom errors and error.cause

After this lesson you can give your program its own error types, decide by type instead of by message text, and keep the original error when you add context.

Lesson 2 of 5 in I4 Errors, debugging and testing

You will be able to

  • Define an error class with extends Error, its own name and extra fields
  • Tell errors apart with instanceof and their fields, not by parsing the message
  • Wrap a low-level error with new Error(message, {cause}) and read error.cause
  1. Warm-up · Activity 1 of 7

    Warm-up from lesson I4.1: a built-in error type. What does this print?

    const list = [];
    try {
      list.length = -1;
    } catch (error) {
      console.log(error.name);
    }
  2. Predict · Activity 2 of 7

    Predict before you read on. The class only extends Error. What does this print?

    class NotFoundError extends Error {}
    
    const error = new NotFoundError("No user with id 2");
    console.log(error.name, error instanceof NotFoundError);
  3. Practice · Activity 3 of 7

    Fill in the argument that hands the cause on to Error.

    super(message, ____);
    super(message, );
  4. Practice · Activity 4 of 7

    Two levels of error classes. What does this print?

    class AppError extends Error {}
    class DbError extends AppError {}
    
    const error = new DbError("Connection lost");
    console.log(error instanceof AppError, error instanceof TypeError);
  5. Practice · Activity 5 of 7

    What does this print?

    const low = new Error("ECONNREFUSED");
    const high = new Error("Could not load users", {cause: low});
    console.log(high.message + " / " + high.cause.message);
  6. Brain teaser · Activity 6 of 7

    Brain teaser. The error is turned into JSON, for example to log it. What does this print?

    class ValidationError extends Error {
      constructor(field, message) {
        super(message);
        this.name = "ValidationError";
        this.field = field;
      }
    }
    
    console.log(JSON.stringify(new ValidationError("email", "Email is missing")));
  7. Apply · Activity 7 of 7

    Mini-task: write OutOfStockError (extends Error, name, an item field, options passed to super) and order(item), which throws it when the stock is 0 and a plain Error for an unknown item. checkout(items) orders each item and wraps any error in new Error("Checkout failed", {cause}). Try ["tea"], ["tea", "milk"] and ["cake"], and print the cause: the item when it is sold out, its message otherwise.

    Check your work against this list

Build it yourself

Read the worked example, then write the exercises. Your code runs in your browser or on your computer and is never uploaded.

Worked example

Two error classes and a wrapped error

NotFoundError carries the id that was not found, and the loop handles only that type, so any other error would still stop the program. ValidationError shows the full pattern: options passed to super, a name, and a field. parseSettings wraps the SyntaxError of JSON.parse in an error that says what the program was doing.

main.js

class ValidationError extends Error {
  constructor(field, message, options) {
    super(message, options); // first: sets message, and cause if given
    this.name = "ValidationError";
    this.field = field;
  }
}

class NotFoundError extends Error {
  constructor(id) {
    super(`No user with id ${id}`);
    this.name = "NotFoundError";
    this.id = id;
  }
}

const users = new Map([[1, {name: "Ada"}]]);

function findUser(id) {
  const user = users.get(id);
  if (!user) throw new NotFoundError(id);
  return user;
}

function parseSettings(text) {
  try {
    return JSON.parse(text);
  } catch (error) {
    throw new Error("Could not read the settings", {cause: error});
  }
}

for (const id of [1, 2]) {
  try {
    console.log(findUser(id).name);
  } catch (error) {
    if (error instanceof NotFoundError) console.log("404:", error.message);
    else throw error;
  }
}

const invalid = new ValidationError("email", "Email is missing");
console.log(String(invalid), invalid.field, invalid instanceof Error);

try {
  parseSettings("{oops");
} catch (error) {
  console.log(error.message, "<-", error.cause.name);
}

Run it with

node main.js

Output

Ada
404: No user with id 2
ValidationError: Email is missing email true
Could not read the settings <- SyntaxError
  • String(error) is name, a colon and message, so setting name makes logs readable.
  • The handler checks the type with instanceof; the message is only for people.
  • error.cause is the whole original error, with its own name, message and stack.
Change it and run it

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads the JavaScript runner (up to 0.1 MB) and keeps it cached. Your code runs in your browser’s own engine and stays on your device.

Exercises

Exercise 1 of 2

An HttpError with a status

HttpError should carry the HTTP status of a failed request: new HttpError(503, "Service unavailable") has the name HttpError, the message, and a status field. isRetryable(error) is true only for an HttpError with a status of 500 or more. The starter’s class sets neither name nor status.

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads the JavaScript runner (up to 0.1 MB) and keeps it cached. Your code runs in your browser’s own engine and stays on your device.

Hints
  1. Hint 1

    Run the tests: what does String(error) show as the name, and what is error.status?

  2. Hint 2

    After super(message), set this.name and this.status.

  3. Hint 3

    Pass options on too, so an HttpError can carry a cause.

Show a solution

One way to solve it. Yours can look different and still pass the checks.

export class HttpError extends Error {
  constructor(status, message, options) {
    super(message, options);
    this.name = "HttpError";
    this.status = status;
  }
}

export function isRetryable(error) {
  return error instanceof HttpError && error.status >= 500;
}
Run it on your computer

Install ECMAScript 2026 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.js

export class HttpError extends Error {
  constructor(status, message) {
    super(message);
  }
}

export function isRetryable(error) {
  return error instanceof HttpError && error.status >= 500;
}

main.test.js

import {test} from 'node:test';
import assert from 'node:assert/strict';
import {HttpError, isRetryable} from './main.js';

test('an HttpError has its name, message and status', () => {
  const error = new HttpError(503, 'Service unavailable');
  assert.equal(String(error), 'HttpError: Service unavailable', `String(error) is ${String(error)}`);
  assert.equal(error.status, 503, `error.status is ${error.status}`);
  assert.ok(error instanceof Error, 'an HttpError should be an Error');
});

test('a 503 can be retried, a 404 cannot', () => {
  assert.equal(isRetryable(new HttpError(503, 'Service unavailable')), true, 'a 503 should be retryable');
  assert.equal(isRetryable(new HttpError(404, 'Not found')), false, 'a 404 should not be retryable');
});

test('other errors are never retried', () => {
  assert.equal(isRetryable(new TypeError('x is not a function')), false, 'a TypeError should not be retryable');
});

package.json

{
  "type": "module"
}

package.json tells Node.js that the .js files are modules; keep it in the folder.

Run the program:

node main.js

Run the checks (needs learnrun.js in the same folder):

node --test
Download learnrun.js

Exercise 2 of 2

Keep the cause

loadConfig(text) parses a config written as JSON. For broken JSON it throws a ConfigError with the message Config is not valid JSON and the SyntaxError as its cause. loadConfig already passes {cause: error}, but the cause is lost on the way. Find where, and fix it.

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads the JavaScript runner (up to 0.1 MB) and keeps it cached. Your code runs in your browser’s own engine and stays on your device.

Hints
  1. Hint 1

    Who reads the options object with the cause in it?

  2. Hint 2

    The constructor of ConfigError never passes the second argument on to Error.

  3. Hint 3

    constructor(message, options) { super(message, options); … }

Show a solution

One way to solve it. Yours can look different and still pass the checks.

export class ConfigError extends Error {
  constructor(message, options) {
    super(message, options);
    this.name = "ConfigError";
  }
}

export function loadConfig(text) {
  try {
    return JSON.parse(text);
  } catch (error) {
    throw new ConfigError("Config is not valid JSON", {cause: error});
  }
}
Run it on your computer

Install ECMAScript 2026 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.js

export class ConfigError extends Error {
  constructor(message) {
    super(message);
    this.name = "ConfigError";
  }
}

export function loadConfig(text) {
  try {
    return JSON.parse(text);
  } catch (error) {
    throw new ConfigError("Config is not valid JSON", {cause: error});
  }
}

main.test.js

import {test} from 'node:test';
import assert from 'node:assert/strict';
import {ConfigError, loadConfig} from './main.js';

test('valid JSON comes back as an object', () => {
  assert.deepEqual(loadConfig('{"port": 8080}'), {port: 8080}, 'loadConfig should parse valid JSON');
});

test('broken JSON throws a ConfigError', () => {
  assert.throws(() => loadConfig('{port'), ConfigError, 'loadConfig should throw a ConfigError');
});

test('the ConfigError keeps the SyntaxError as its cause', () => {
  try {
    loadConfig('{port');
    assert.fail('loadConfig did not throw');
  } catch (error) {
    assert.ok(error.cause instanceof SyntaxError, `error.cause is ${error.cause}`);
  }
});

package.json

{
  "type": "module"
}

package.json tells Node.js that the .js files are modules; keep it in the folder.

Run the program:

node main.js

Run the checks (needs learnrun.js in the same folder):

node --test
Download learnrun.js

Common mistakes

Using this before super()

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

new ValidationError("email", "Email is missing");

What Node.js prints

ReferenceError: Must call super constructor in derived class before accessing 'this' or returning from derived constructor

Why, and the fix

In a class that extends another, the object is created by the parent constructor, so this does not exist before super() has run. Call super(message, options) as the first line of the constructor, then set name and the other fields.

A subclass that drops the options

class LoadError extends Error {
  constructor(message) {
    super(message);
    this.name = "LoadError";
  }
}

try {
  throw new LoadError("Could not load", {cause: new Error("timeout")});
} catch (error) {
  console.log(error.cause.message);
}

What Node.js prints

TypeError: Cannot read properties of undefined (reading 'message')

Why, and the fix

Error only sets cause from the options object it receives, and this constructor never passes it on, so error.cause is undefined. Accept options and hand them to super: constructor(message, options) { super(message, options); … }.

Calling an error class without new

class NotFoundError extends Error {
  constructor(id) {
    super(`No user with id ${id}`);
    this.name = "NotFoundError";
  }
}

throw NotFoundError(2);

What Node.js prints

TypeError: Class constructor NotFoundError cannot be invoked without 'new'

Why, and the fix

The built-in Error() also works without new, but a class never does. Write throw new NotFoundError(2).

JavaScript in the browser: your browser’s own engine, in a sandboxed worker. Syntax errors are located with acorn 8.18.0, MIT. Licence and source

Exit ticket

5 questions, no hints. Score 80% or more to complete the lesson.

Finish every activity above to unlock the exit ticket.

Report a problem

Spotted something wrong or unclear? Say what, and it will be checked and fixed.

#

At least 20 characters.

Only if you want a reply.

Key ideas

An error class of your own

class ValidationError extends Error makes a new error type. In its constructor, call super(message, options) first: it sets message, and cause if options has one. Then set this.name = "ValidationError", or the error still calls itself Error in messages and logs, and add fields that help the handler, such as this.field = "email". The class is an Error too: it has a stack, works with throw, and instanceof Error is true.

Decide by type, not by text

error instanceof NotFoundError asks what kind of error it is; error.status or error.field tell the details. Code that compares error.message with a text breaks as soon as someone improves the wording. instanceof also matches subclasses, so with class DbError extends AppError, check the more specific class first. Promise.any rejects with an AggregateError, whose errors array holds every single error.

Add context, keep the cause

A low-level error such as SyntaxError: Unexpected token o says little about what the program was doing. Catch it and throw a new error that says so, passing the original as the cause: throw new Error("Could not read the settings", {cause: error}). The handler reads error.cause, and Node.js prints both errors with their stacks. A custom class must pass options on to super(), or the cause is lost.

Sources

Last reviewed October 5, 2026