error-extender
v2.0.1
Published
Simplifies creation of custom Error classes for Node.js
Maintainers
Readme
error-extender
Simplifies creation of custom Error classes for Node.js, with cause-chaining stack traces (à la Java) and deep-merging defaults.
Why error-extender?
Modern JavaScript has Error.cause and class-based custom errors — but there are real gaps that error-extender fills.
Cause chains appear in logs without any custom formatter
Most loggers (Winston, Pino, etc.) and log aggregators serialize only error.stack. Native Error.cause is invisible to them unless you write a custom serializer for every tool in your pipeline. error-extender appends each Caused by: frame directly into the stack string, so the full chain appears wherever stack traces are written — no extra setup.
// Native — cause is invisible in error.stack
const root = new Error('connection refused');
const err = new Error('query failed', { cause: root });
console.error(err.stack);
// Error: query failed
// at Object.<anonymous> (/app/index.js:2:13)
// ...
// (cause never appears — most loggers stop here)
// error-extender — full chain baked into the stack string
const { extendError } = require('error-extender');
const DatabaseError = extendError('DatabaseError');
const root = new Error('connection refused');
const err = new DatabaseError({ message: 'query failed', cause: root });
console.error(err.stack);
// DatabaseError: query failed
// at Object.<anonymous> (/app/index.js:8:13)
// ...
// Caused by: Error: connection refused
// at Object.<anonymous> (/app/index.js:7:14)
// ...Typed, structured context on every error
Native Error has no data field. Without error-extender, attaching structured context (an HTTP status, a request ID, an affected resource) requires manual boilerplate on every custom class.
// Native — boilerplate repeated on every custom error class
class HttpError extends Error {
readonly status: number;
readonly body?: string;
constructor(message: string, status: number, body?: string) {
super(message);
this.name = 'HttpError';
this.status = status;
this.body = body;
}
}
// error-extender — typed data in one call, no boilerplate
import { extendError } from 'error-extender';
interface HttpErrorData { status: number; body?: string }
const HttpError = extendError<HttpErrorData>('HttpError');
const err = new HttpError({ data: { status: 404, body: 'Not Found' } });
err.data?.status; // number — fully typedDefault values cascade and deep-merge down the hierarchy
Define defaultMessage and defaultData once at the parent level; child errors inherit them automatically. When both parent and child defaultData are plain objects, they deep-merge (child wins on conflict) — so a DatabaseError can inherit { status: 500 } from ServiceError and only override what it needs.
import { extendError } from 'error-extender';
const ServiceError = extendError('ServiceError', {
defaultMessage: 'A service error has occurred.',
defaultData: { status: 500 },
});
const DatabaseError = extendError('DatabaseError', {
parent: ServiceError,
// no defaultMessage — inherits from ServiceError
defaultData: { message: 'A database error has occurred.' },
});
console.log(DatabaseError.defaultData);
// { status: 500, message: 'A database error has occurred.' }
// ^^ status inherited from ServiceError, message added by DatabaseError
const err = new DatabaseError(); // no args needed
console.log(err.message); // 'A service error has occurred.' (inherited)
console.log(err.data); // { status: 500, message: 'A database error has occurred.' }Stack traces point at your code, not library internals
error-extender uses Error.captureStackTrace to remove its own frames from the stack, so every trace starts at the call site in your application.
// Without captureStackTrace — library internals pollute the top of the stack
Error: something went wrong
at new ExtendedErrorImpl (node_modules/error-extender/dist/index.js:18:5)
at Object.<anonymous> (/app/service.js:12:9) <-- your code, buried
...
// error-extender — trace starts directly at your call site
ServiceError: something went wrong
at Object.<anonymous> (/app/service.js:12:9) <-- your code, first
...Install
npm install error-extenderQuick Start
TypeScript
import { extendError } from 'error-extender';
const CustomError = extendError('CustomError');
const rootCause = new Error('something broke deep down');
throw new CustomError({ message: 'An error has occurred.', cause: rootCause });Plain JS (CommonJS)
const { extendError } = require('error-extender');
const CustomError = extendError('CustomError');
const rootCause = new Error('something broke deep down');
throw new CustomError({ message: 'An error has occurred.', cause: rootCause });The thrown error's .stack will include the full cause chain:
CustomError: An error has occurred.
at Object.<anonymous> (/opt/app/index.js:7:7)
...
Caused by: Error: something broke deep down
at Object.<anonymous> (/opt/app/index.js:5:19)
...Features
Creating Custom Error Classes
// TypeScript
import { extendError } from 'error-extender';
const AppError = extendError('AppError');// Plain JS
const { extendError } = require('error-extender');
const AppError = extendError('AppError');The second argument accepts options:
| key | type | description |
| ---------------- | ---------------------------------------- | ---------------------------------------- |
| parent | Error constructor (or subclass of it) | Parent error class to extend (default: Error) |
| defaultMessage | string | Fallback message when none is provided |
| defaultData | any | Fallback data (deep-merged with instance data if both are plain objects) |
Error Hierarchies
Custom errors can extend other custom errors. instanceof checks work across the full hierarchy.
// TypeScript
import { extendError } from 'error-extender';
const AppError = extendError('AppError');
const ServiceError = extendError('ServiceError', { parent: AppError });
const DatabaseError = extendError('DatabaseError', { parent: ServiceError });
const err = new DatabaseError();
console.log(err instanceof DatabaseError); // true
console.log(err instanceof ServiceError); // true
console.log(err instanceof AppError); // true
console.log(err instanceof Error); // true// Plain JS
const { extendError } = require('error-extender');
const AppError = extendError('AppError');
const ServiceError = extendError('ServiceError', { parent: AppError });
const DatabaseError = extendError('DatabaseError', { parent: ServiceError });
const err = new DatabaseError();
console.log(err instanceof DatabaseError); // true
console.log(err instanceof ServiceError); // true
console.log(err instanceof AppError); // true
console.log(err instanceof Error); // trueDefault Message & Data Inheritance
defaultMessage and defaultData cascade down the hierarchy. Children inherit parent defaults and can override or extend them.
// TypeScript
import { extendError } from 'error-extender';
const AppError = extendError('AppError', {
defaultMessage: 'An unhandled error has occurred.',
defaultData: { status: 503, message: 'Service unavailable.' },
});
const ServiceError = extendError('ServiceError', {
parent: AppError,
defaultMessage: 'A service error has occurred.',
defaultData: { status: 500, message: 'Internal server error.' },
});
const DatabaseError = extendError('DatabaseError', {
parent: ServiceError,
// no defaultMessage — inherits ServiceError's
defaultData: { message: 'A database error has occurred.' },
});
console.log(DatabaseError.defaultData);
// { status: 500, message: 'A database error has occurred.' }
// ^^ status inherited from ServiceError, message overridden// Plain JS
const { extendError } = require('error-extender');
const AppError = extendError('AppError', {
defaultMessage: 'An unhandled error has occurred.',
defaultData: { status: 503, message: 'Service unavailable.' },
});
const ServiceError = extendError('ServiceError', {
parent: AppError,
defaultMessage: 'A service error has occurred.',
defaultData: { status: 500, message: 'Internal server error.' },
});
const DatabaseError = extendError('DatabaseError', {
parent: ServiceError,
defaultData: { message: 'A database error has occurred.' },
});
console.log(DatabaseError.defaultData);
// { status: 500, message: 'A database error has occurred.' }Constructor Options
Custom errors accept a single options object:
| key | alias | type | description |
| :-------- | :---: | ------------------- | -------------------------------- |
| message | m | string | Error message |
| data | d | any | Arbitrary attached data |
| cause | c | instanceof Error | The underlying cause |
Aliases (m, d, c) are evaluated first; if m is truthy it takes precedence over message.
// TypeScript
import { extendError } from 'error-extender';
const ServiceError = extendError('ServiceError');
try {
// ...something that throws
} catch (err) {
throw new ServiceError({
message: 'Failed to call downstream service.',
data: { ref: '7e9f876ca116' },
cause: err as Error,
});
}// Plain JS
const { extendError } = require('error-extender');
const ServiceError = extendError('ServiceError');
try {
// ...something that throws
} catch (err) {
throw new ServiceError({
message: 'Failed to call downstream service.',
data: { ref: '7e9f876ca116' },
cause: err,
});
}Instance Properties
In addition to the standard name, message, and stack:
| property | description |
| -------- | ---------------------------------------- |
| data | The resolved data (instance data deep-merged with defaultData when both are plain objects) |
| cause | The causing Error instance |
Instance data Merges with defaultData
When both defaultData and the instance data are plain objects, they are deep-merged (instance values win on conflict).
// TypeScript
import { extendError } from 'error-extender';
const AppError = extendError('AppError', {
defaultData: { status: 503, message: 'Service unavailable.' },
});
const err = new AppError({ data: { status: 401 } });
console.log(err.data);
// { status: 401, message: 'Service unavailable.' }
// ^^ status overridden, message filled from defaultData// Plain JS
const { extendError } = require('error-extender');
const AppError = extendError('AppError', {
defaultData: { status: 503, message: 'Service unavailable.' },
});
const err = new AppError({ data: { status: 401 } });
console.log(err.data);
// { status: 401, message: 'Service unavailable.' }Cause Chain in Stack Traces
Each Caused by: section appends the full stack of the causing error, arbitrarily deep.
// TypeScript
import { extendError } from 'error-extender';
const ServiceError = extendError('ServiceError');
const DatabaseError = extendError('DatabaseError', { parent: ServiceError });
try {
try {
throw new Error('connection refused');
} catch (root) {
throw new DatabaseError({ message: 'Query failed.', cause: root as Error });
}
} catch (dbErr) {
throw new ServiceError({ message: 'Could not load user.', cause: dbErr as Error });
}// Plain JS
const { extendError } = require('error-extender');
const ServiceError = extendError('ServiceError');
const DatabaseError = extendError('DatabaseError', { parent: ServiceError });
try {
try {
throw new Error('connection refused');
} catch (root) {
throw new DatabaseError({ message: 'Query failed.', cause: root });
}
} catch (dbErr) {
throw new ServiceError({ message: 'Could not load user.', cause: dbErr });
}Stack trace output:
ServiceError: Could not load user.
at Object.<anonymous> (/opt/app/index.js:14:9)
...
Caused by: DatabaseError: Query failed.
at Object.<anonymous> (/opt/app/index.js:10:11)
...
Caused by: Error: connection refused
at Object.<anonymous> (/opt/app/index.js:7:11)
...TypeScript: Typed data
Pass a type parameter to get full type safety on data and defaultData.
import { extendError } from 'error-extender';
interface HttpErrorData {
status: number;
body?: string;
}
const HttpError = extendError<HttpErrorData>('HttpError', {
defaultData: { status: 500 },
});
const err = new HttpError({ data: { status: 404, body: 'Not Found' } });
console.log(err.data?.status); // 404 (typed as HttpErrorData)100% Code Coverage
Test coverage is verified on every build via npm test.
License
Zero-Clause BSD License (0BSD)
Copyright (c) 2018 Joseph Baking
Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
