JavaScript (ES2026) Module 9 – Errors and debugging
Custom errors, error cause and Error.isError
Create error classes with a name and their own fields, wrap low-level failures with the cause option, and recognise real error objects with Error.isError.
What you will learn
- Create custom error classes that set their own name and carry extra fields
- Wrap a low-level error with the cause option and print the whole chain of causes
- Detect real error objects with Error.isError, including errors from another realm
- Serialize an error, its fields and its causes into JSON for a log
Before you start
What you’ll learn · Before you start
What you will learn
- Create custom error classes that set their own name and carry extra fields
- Wrap a low-level error with the cause option and print the whole chain of causes
- Detect real error objects with Error.isError, including errors from another realm
- Serialize an error, its fields and its causes into JSON for a log
Before you start
On this page
The built-in classes describe failures of the language, and a program also has failures of its own: a seat that is
already booked, a card the bank declined, a request that came back with status 503. Give each of them a custom
error class, a class that extends Error, sets its own name and carries the data a caller needs, such as the seat
or the status code. Callers can then tell one failure from another with instanceof and read the data instead of
parsing a message. When one failure leads to another, the cause option, new Error(message, { cause }) from
ECMAScript 2022, keeps the original error attached to the new one, so a log can show the whole chain. And
Error.isError(value), new in ECMAScript 2026, tells whether a value really is an error object, which
instanceof cannot always do. This lesson builds all three and ends with errors that survive the trip into a log.
A custom error class
A custom error needs only a few lines of class syntax, which a later module of this track covers in full. The class
extends Error, passes the message to super, and adds what makes it useful:
// A custom error: a class that extends Error, sets its name and carries the data callers need.
class SeatTakenError extends Error {
constructor(seat, options) {
super(`seat ${seat} is already booked`, options);
this.name = 'SeatTakenError';
this.seat = seat;
}
}
function book(seat, taken) {
if (taken.has(seat)) throw new SeatTakenError(seat);
taken.add(seat);
return `booked ${seat}`;
}
const taken = new Set(['12A']);
for (const seat of ['14C', '12A']) {
try {
console.log(book(seat, taken));
} catch (error) {
if (!(error instanceof SeatTakenError)) throw error;
console.log(`${error.name}: ${error.message} (seat field: ${error.seat})`);
console.log(`instanceof Error: ${error instanceof Error}, String(): ${String(error)}`);
}
}
class NoName extends Error {}
console.log(`without a name of its own: ${String(new NoName('seat 12A is already booked'))}`); Output
booked 14C SeatTakenError: seat 12A is already booked (seat field: 12A) instanceof Error: true, String(): SeatTakenError: seat 12A is already booked without a name of its own: Error: seat 12A is already booked
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node custom-class.mjs
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
super(message, options) runs the Error constructor, which stores the message, records the stack and, when the
options hold one, the cause. The constructor then sets name, because a subclass otherwise inherits the name
'Error' from Error.prototype: the last line of the output shows what NoName looks like in a log, where nothing
distinguishes it from any other error. seat is the data the caller needs. The catch block checks the class, and
rethrows anything else, so a bug in book would not pass for a booked seat.
Setting the name so that it survives the build
There are three common ways to set the name, and each one works:
this.name = 'SeatTakenError'in the constructor, as above;- a class field,
name = 'SeatTakenError';, which does the same for every instance; - an assignment on the prototype,
SeatTakenError.prototype.name = 'SeatTakenError';, which is how the built-in classes hold their names.
The first two give every error an own, enumerable name property, which JSON.stringify and Object.keys then see;
the third keeps one shared name on the prototype. What does not work reliably is copying the name from the class with
this.name = new.target.name or this.constructor.name, because minifiers rename classes to save bytes:
JavaScript · minified-names.mjs
// The same two error classes, as written and as a minifier ships them (dist/name-from-class.min.mjs was made with
// esbuild 0.28.2: --bundle --minify --format=esm).
import * as written from './name-from-class.mjs';
import * as shipped from './dist/name-from-class.min.mjs';
for (const [label, errors] of [['as written', written], ['as shipped', shipped]]) {
const seat = new errors.SeatTakenError('seat 12A is already booked');
const card = new errors.PaymentDeclinedError('the bank declined the card');
console.log(`${label}: ${seat.name}, ${card.name}`);
} Output
as written: SeatTakenError, PaymentDeclinedError as shipped: t, x
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node minified-names.mjs
As written · name-from-class.mjs
// Error classes that copy their name from the class. Fine in this file; the bundler's minified copy is another story.
class AppError extends Error {
constructor(message, options) {
super(message, options);
this.name = new.target.name; // the name of the class that was called with new
}
}
export class SeatTakenError extends AppError {}
export class PaymentDeclinedError extends AppError {} As shipped · dist/name-from-class.min.mjs
var s=class extends Error{constructor(c,n){super(c,n),this.name=new.target.name}},t=class extends s{},x=class extends s{};export{x as PaymentDeclinedError,t as SeatTakenError}; Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
In the minified copy the classes are called t and x, so the errors are named after them, and an error dashboard
would group production failures under those letters. Bundlers can keep class names, through esbuild’s keep-names
setting or terser’s keep_classnames, but a name written out as a string is correct whatever the build does.
Wrapping a failure with cause
When code catches a low-level error and throws one of its own, the new error should say what the program was trying
to do, and keep the old one, which says what actually broke. The cause option links them:
// When an error gets a cause property, and what it looks like.
const original = new SyntaxError('bad JSON');
const wrapped = new Error('config.json is not valid JSON', { cause: original });
console.log('wrapped.cause is the original:', wrapped.cause === original);
console.log('own property:', Object.hasOwn(wrapped, 'cause'), '| enumerable:', Object.getOwnPropertyDescriptor(wrapped, 'cause').enumerable);
console.log('no options, a cause property?', Object.hasOwn(new Error('x'), 'cause'));
console.log('{ cause: undefined }, a cause property?', Object.hasOwn(new Error('x', { cause: undefined }), 'cause'));
console.log('a cause can be any value:', JSON.stringify(new Error('retry later', { cause: { status: 503, after: 30 } }).cause)); Output
wrapped.cause is the original: true
own property: true | enumerable: false
no options, a cause property? false
{ cause: undefined }, a cause property? true
a cause can be any value: {"status":503,"after":30}
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node cause-rules.mjs
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
The rules come from the Error constructor in ECMA-262. An error gets a cause property only when the options object
has one, even if its value is undefined. The property is the error’s own, and it is not enumerable, so loops and
JSON.stringify skip it. Its value can be anything: usually the caught error, sometimes a small object of facts such
as a status code.
A chain of causes
Text description of the diagram
The chart shows one failure as three linked errors, from top to bottom.
- The caller catches the outermost error and checks its class and its `status`. - That error is an `HttpError` with the message "HTTP 503 for /api/seats" and the fields `status: 503` and `url: /api/seats`. Its `cause` is the error it was created to report. - The cause is a `TypeError` with the message "fetch failed", and its own `cause` is the first failure. - The first failure is an `Error` with the message "connect ECONNREFUSED 10.0.0.7:443" and the code `ECONNREFUSED`: the connection was refused.
Code decides what to do from the outermost error; a log follows `cause` from there down to the first failure.
A config loader shows the pattern at work. It turns two different low-level failures into one class of its own, with a message about the file, and keeps each original failure as the cause:
// A config loader that wraps a low-level failure in an error of its own and keeps the original as its cause.
class ConfigError extends Error {
constructor(message, options) {
super(message, options);
this.name = 'ConfigError';
}
}
const files = new Map([['config.json', '{ "port": 8080, }']]); // stands in for the file system
function readText(path) {
if (!files.has(path)) throw new Error(`no such file: ${path}`);
return files.get(path);
}
function loadConfig(path) {
let text;
try {
text = readText(path);
} catch (error) {
throw new ConfigError(`could not read ${path}`, { cause: error });
}
try {
return JSON.parse(text);
} catch (error) {
throw new ConfigError(`${path} is not valid JSON`, { cause: error });
}
}
/** One line per error of the chain, following cause from the outermost error inwards. */
function chain(error) {
const lines = [];
for (let e = error, depth = 0; e !== undefined && depth < 10; e = e.cause, depth++) {
lines.push(`${depth === 0 ? '' : 'caused by '}${e.name}: ${e.message}`);
}
return lines.join('\n');
}
for (const path of ['config.json', 'settings.json']) {
try {
loadConfig(path);
} catch (error) {
console.log(chain(error));
}
} Output
ConfigError: config.json is not valid JSON caused by SyntaxError: Expected double-quoted property name in JSON at position 16 (line 1 column 17) ConfigError: could not read settings.json caused by Error: no such file: settings.json
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node error-chain.mjs
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
Version note
In the Run button’s QuickJS engine the SyntaxError from JSON.parse says expecting property name; the two
ConfigError lines and the missing-file error are the same, because this program wrote those messages itself.
In your browser, QuickJS (quickjs-emscripten 0.32.0) prints:
ConfigError: config.json is not valid JSON caused by SyntaxError: expecting property name ConfigError: could not read settings.json caused by Error: no such file: settings.json
The caller needs to know one thing, that loading the configuration failed, and gets it from the outer class. Whoever
reads the log needs to know why, and chain follows cause down to the first failure. Node.js does the same when it
prints an error: console.log shows every cause after the label [cause].
// How Node.js prints an error with a cause. Stack traces are switched off here (Error.stackTraceLimit = 0, a V8
// setting) so that only the structure shows.
Error.stackTraceLimit = 0;
class ConfigError extends Error {
constructor(message, options) {
super(message, options);
this.name = 'ConfigError';
}
}
const error = new ConfigError('config.json is not valid JSON', { cause: new SyntaxError('Unexpected end of JSON input') });
console.log(error); Output
[ConfigError: config.json is not valid JSON] {
[cause]: [SyntaxError: Unexpected end of JSON input]
}
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node node-inspect-cause.mjs
Three habits make wrapping worth it. Write the new message about the operation (could not read settings.json), not
about the symptom. Choose the class for the caller who has to react to it. And always pass the caught error as
cause: ESLint’s preserve-caught-error rule, part of its recommended set, reports a catch block that throws a new
error without it.
Recognising error objects: instanceof and Error.isError
error instanceof Error asks whether Error.prototype is in the value’s prototype chain. That is usually the same
as asking whether the value is an error, but not always:
// instanceof asks about the prototype chain; Error.isError asks whether the value really is an error object.
const real = new TypeError('a real error');
const fake = Object.create(Error.prototype, { message: { value: 'not an error at all' } });
const disguised = { [Symbol.toStringTag]: 'Error', message: 'pretends to be one' };
for (const [label, value] of [['real', real], ['fake', fake], ['disguised', disguised], ['a string', 'boom']]) {
console.log(
`${label.padEnd(10)} instanceof Error: ${String(value instanceof Error).padEnd(5)} Error.isError: ${String(Error.isError(value)).padEnd(5)} toString tag: ${Object.prototype.toString.call(value)}`,
);
}
/** What a catch block can do with anything thrown: keep real errors, wrap everything else. */
function asError(value) {
return Error.isError(value) ? value : new Error(`a non-error value was thrown: ${String(value)}`, { cause: value });
}
console.log(asError('boom').message, '| cause:', asError('boom').cause); Output
real instanceof Error: true Error.isError: true toString tag: [object Error] fake instanceof Error: true Error.isError: false toString tag: [object Object] disguised instanceof Error: false Error.isError: false toString tag: [object Error] a string instanceof Error: false Error.isError: false toString tag: [object String] a non-error value was thrown: boom | cause: boom
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node is-error.mjs
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
The fake object has Error.prototype in its chain, so instanceof accepts it, although no Error constructor ever
made it. The disguised object borrows the tag Object.prototype.toString reports, the old way of recognising errors.
Error.isError is fooled by neither: ECMA-262 defines it as a check of an internal slot that only the error
constructors set, so it returns true exactly for objects that an error constructor created. The asError helper at
the end shows a common use, in a catch block that must cope with anything thrown: keep real errors, and wrap any
other value in an Error with the value as its cause.
The opposite mistake happens across realms. Each realm, such as an iframe in a browser or a node:vm context, has
its own set of built-in constructors, so an error made in one is not an instance of another realm’s Error:
// An error made in another realm (here a node:vm context; in a browser, an iframe) fails instanceof but not Error.isError.
import { runInNewContext } from 'node:vm';
const foreign = runInNewContext('new TypeError("made in another realm")');
console.log('instanceof TypeError:', foreign instanceof TypeError);
console.log('instanceof Error:', foreign instanceof Error);
console.log('Error.isError:', Error.isError(foreign));
console.log('name:', foreign.name); Output
instanceof TypeError: false instanceof Error: false Error.isError: true name: TypeError
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node cross-realm.mjs
instanceof says no twice, and Error.isError gives the right answer. Node.js 24 documents
util.types.isNativeError as deprecated in favour of Error.isError. Node.js 24 and the Run button’s QuickJS engine
have it, while MDN still lists it as not available in every widely used browser, so check your targets, or use one of
the polyfills it names. Some code also calls Error.captureStackTrace(this, SomeError), an API that V8 introduced to
leave helper functions out of a stack; it is a Stage 2 proposal at TC39, not part of the standard, and a class that
extends Error does not need it.
Errors in logs and API responses
JSON.stringify writes an object’s own enumerable properties, and an error’s message, stack and cause are none
of those. Writing errors into a log as JSON therefore needs a function of its own:
// JSON.stringify sees only own enumerable properties, so errors need a serializer of their own for logs.
class PaymentError extends Error {
constructor(message, { code, ...options } = {}) {
super(message, options);
this.name = 'PaymentError';
this.code = code;
}
}
const error = new PaymentError('card declined', { code: 'CARD_DECLINED', cause: new Error('issuer said no') });
console.log('JSON.stringify(new Error):', JSON.stringify(new Error('lost')));
console.log('JSON.stringify(PaymentError):', JSON.stringify(error));
/** name, message, every own field and the cause chain, at most `depth` levels deep. */
function serializeError(value, depth = 5) {
if (!Error.isError(value)) return value;
const out = { name: value.name, message: value.message };
for (const key of Object.keys(value)) if (key !== 'name') out[key] = value[key];
if ('cause' in value) out.cause = depth > 0 ? serializeError(value.cause, depth - 1) : '[cause chain too deep]';
return out;
}
console.log(JSON.stringify(serializeError(error), null, 2)); Output
JSON.stringify(new Error): {}
JSON.stringify(PaymentError): {"name":"PaymentError","code":"CARD_DECLINED"}
{
"name": "PaymentError",
"message": "card declined",
"code": "CARD_DECLINED",
"cause": {
"name": "Error",
"message": "issuer said no"
}
}
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node serialize-error.mjs
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
A plain Error comes out as {}, and the custom error keeps only the fields its constructor assigned, without its
message. serializeError writes the name and the message, every own field such as code, and the cause chain, with
a depth limit so that a chain which loops back on itself cannot run for ever. Two cautions apply before such an object
leaves the program: a stack trace reveals file paths and code structure, so it belongs in internal logs rather than
in a response to a browser, and fields copied from a request can hold personal data or tokens, which the next lesson
keeps out of logs.
Exercise: an HttpError and the chain of its causes
Exercise · Medium · JavaScript
An HttpError and the chain of its causes
A booking page calls an API, and when a request fails it throws an HttpError. Finish the class and write describeError, which turns an error and its causes into text for a log.
HttpError:
new HttpError(status, url, options)is anErrorwhosenameis'HttpError';- it has
statusandurlproperties, and its message isHTTP <status> for <url>, such asHTTP 503 for /api/seats; - it keeps a cause exactly as
Errordoes:new HttpError(503, url, { cause: error })has thatcause, and anHttpErrormade without the option has nocauseproperty at all.
describeError(error) returns one line per error, outermost first, joined with \n:
- the first line is
<name>: <message>, and every cause after it iscaused by <name>: <message>; - a cause that is not an error object (check it with
Error.isError) is printed as JSON, such ascaused by {"retryAfter":30}; - stop when there is no
causeproperty; a chain that comes back to an error already printed, or that is more than 10 causes long, must not loop for ever.
Starter code · http-error.mjs
/**
* A failed HTTP request: the status code, the URL and, when there was one, the error that caused it.
* Fix this class and write describeError below it.
*/
export class HttpError extends Error {
constructor(status, url) {
super('request failed');
}
}
/**
* The error and every cause behind it, one line each, outermost first:
* HttpError: HTTP 503 for /api/seats
* caused by TypeError: fetch failed
* @param {unknown} error
* @returns {string}
*/
export function describeError(error) {
return String(error);
} The sample tests · http-error.test.mjs
import { test, assert } from 'mysmartcopilot:test';
import { HttpError, describeError } from './http-error.mjs';
test('an HttpError is an Error with its own name, status and url', () => {
const error = new HttpError(404, '/api/seats/12A');
assert.ok(error instanceof HttpError && error instanceof Error, 'expected an instance of HttpError and Error');
assert.equal(error.name, 'HttpError');
assert.equal(error.status, 404);
assert.equal(error.url, '/api/seats/12A');
});
test('the message names the status and the url', () => {
assert.equal(new HttpError(503, '/api/seats').message, 'HTTP 503 for /api/seats');
});
test('a cause is kept when one is given, and only then', () => {
const low = new TypeError('fetch failed');
assert.equal(new HttpError(503, '/api/seats', { cause: low }).cause, low);
assert.equal(Object.hasOwn(new HttpError(503, '/api/seats'), 'cause'), false, 'no cause option, no cause property');
});
test('describeError prints one line per error, outermost first', () => {
const root = new Error('connect ECONNREFUSED 10.0.0.7:443');
const middle = new TypeError('fetch failed', { cause: root });
const top = new HttpError(503, '/api/seats', { cause: middle });
assert.equal(describeError(top), 'HttpError: HTTP 503 for /api/seats\ncaused by TypeError: fetch failed\ncaused by Error: connect ECONNREFUSED 10.0.0.7:443');
});
test('describeError of an error without a cause is a single line', () => {
assert.equal(describeError(new RangeError('page 99 does not exist')), 'RangeError: page 99 does not exist');
});
test('a cause that is not an error is printed as JSON', () => {
const error = new HttpError(429, '/api/seats', { cause: { retryAfter: 30 } });
assert.equal(describeError(error), 'HttpError: HTTP 429 for /api/seats\ncaused by {"retryAfter":30}');
}); A hint
Pass options straight on to super(message, options): the Error constructor creates cause only when the options have one, which is exactly the rule the tests check. For describeError, walk the chain with a loop, current = current.cause, and keep the errors already printed in a Set so that a chain that loops can stop.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (QuickJS): nothing is sent to mysmartcopilot.com. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Self-check
Check yourself
7 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.
Interview questions
Fresher · What does the cause option of the Error constructor do, and why pass it when you rethrow?
new Error(message, { cause }), standard since ECMAScript 2022, stores the value of cause on the new error as a
cause property, which is the error’s own and not enumerable; the property exists only when the options object has
one. Its purpose is to link a high-level error to the failure that produced it. When a function catches, say, a
SyntaxError from JSON.parse and throws a ConfigError that names the file, the caller gets a clear class and
message, and the original error, with its own message and stack, stays reachable as error.cause. Without it, the
rethrow discards the evidence of what actually broke. Node.js prints causes when it logs an error, and ESLint’s
preserve-caught-error rule reports a catch block that throws a new error without one.
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Mid to senior
After the front end started shipping minified bundles, the error dashboard groups production failures under names such as "t" and "x" instead of SeatTakenError and PaymentDeclinedError. What happened, and how would you fix the error classes and keep it from coming back?
-
Mid to senior
An API client library exports HttpError. In one service, error instanceof HttpError is false for errors the client clearly threw, while the same check works in other services. Give two likely causes, and make the check reliable.
Key takeaways
- A custom error class extends
Error, passes the message and options tosuper, setsnameand adds the fields callers need; callers check it withinstanceofand rethrow anything else. - Write the name out as a string: minifiers rename classes, so
new.target.nameandconstructor.namechange in production builds. new Error(message, { cause })keeps the original failure as an own, non-enumerablecause; wrap errors to add what the program was doing, and always pass the caught error along.Error.isErrorchecks whether a value really is an error object: it rejects fakes thatinstanceofaccepts and accepts errors from other realms thatinstanceofrejects.JSON.stringifydrops an error’s message, stack and cause; serialize errors with a function that writes them, and keep stack traces and personal data out of anything a browser receives.
References
- ECMAScript 2026 Language Specification: the Error constructor (Ecma International)
- ECMAScript 2026 Language Specification: InstallErrorCause (Ecma International)
- ECMAScript 2026 Language Specification: Error.isError (Ecma International)
- Error.isError proposal (Ecma International (TC39))
- Error.isError() (MDN Web Docs)
- Error: cause (MDN Web Docs)
- Node.js 24 documentation: deprecated APIs (DEP0197, util.types.isNativeError) (OpenJS Foundation (Node.js))
- preserve-caught-error (ESLint)
- esbuild API: keep names (esbuild)
- TC39 active proposals (Error.captureStackTrace) (Ecma International (TC39))
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress