Creating Custom Error Classes
The built-in Error carries a message and a stack trace, but in a real application that is rarely enough. You want to know what kind of failure occurred — a validation problem, a missing record, an upstream timeout — and branch on it without parsing error strings. Custom error classes give each failure a stable type and a machine-readable code, so callers can handle them precisely with instanceof and centralized handlers can map them to HTTP status codes or retry logic.
Extending the Error class
A custom error is just a class that extends Error. The critical detail is calling super(message) so the base constructor records the message and initializes the stack, then setting this.name to the class name so it appears correctly in logs and stringified output. Always assign a stable, machine-readable code too — code is what your handling logic should switch on, never the human-facing message.
class AppError extends Error {
constructor(message, code) {
super(message);
this.name = this.constructor.name;
this.code = code;
}
}
const err = new AppError('User not found', 'USER_NOT_FOUND');
console.log(err.name); // class name
console.log(err.code); // machine-readable code
console.log(err.message); // human message
console.log(err instanceof Error); // still a real Error
console.log(`${err}`); // default Error toString
Output:
AppError
USER_NOT_FOUND
User not found
true
AppError: User not found
Setting this.name from this.constructor.name means every subclass automatically reports its own name without repeating the string in each constructor.
A common mistake is forgetting
super(message). Without it themessageandstackare never set, so your error logs an empty message and a useless trace. Always callsuperfirst.
Capturing a clean stack trace
The stack property is set by the V8 engine when the error is constructed. By default the trace includes the custom constructor frame itself, which is noise — you want the trace to start at the line that actually threw. Error.captureStackTrace(this, this.constructor) rewrites the stack to begin at the caller, omitting the constructor. It is a V8-specific API (available in Node.js) and is best wrapped in a guard for portability.
class ValidationError extends Error {
constructor(message, field) {
super(message);
this.name = 'ValidationError';
this.code = 'VALIDATION_FAILED';
this.field = field;
if (Error.captureStackTrace) {
Error.captureStackTrace(this, ValidationError);
}
}
}
function createUser(name) {
if (!name) throw new ValidationError('name is required', 'name');
return { name };
}
createUser('');
Output:
ValidationError: name is required
at createUser (file:///app/index.js:16:15)
at file:///app/index.js:19:1
The trace now points straight at createUser, with no ValidationError constructor frame in the way.
Typed handling with instanceof
Because custom errors keep their prototype chain, instanceof works at every level — an instance is both a ValidationError and an Error. This lets a single catch block distinguish failure types and respond differently, while still falling through to a generic branch for anything unexpected.
class NotFoundError extends AppError {
constructor(resource) {
super(`${resource} not found`, 'NOT_FOUND');
this.status = 404;
}
}
class TimeoutError extends AppError {
constructor(ms) {
super(`Operation timed out after ${ms}ms`, 'TIMEOUT');
this.status = 504;
}
}
function handle(err) {
if (err instanceof NotFoundError) return `404: ${err.message}`;
if (err instanceof TimeoutError) return `504: ${err.message}`;
if (err instanceof AppError) return `App error (${err.code})`;
return `Unknown: ${err.message}`;
}
console.log(handle(new NotFoundError('User')));
console.log(handle(new TimeoutError(5000)));
console.log(handle(new Error('boom')));
Output:
404: User not found
504: Operation timed out after 5000ms
Unknown: boom
Branching on code is equally valid and survives bundling or serialization better than instanceof across module boundaries — both are good, code is the more robust default.
Preserving the original cause
When you catch a low-level error and rethrow a domain-specific one, attach the original via the standard cause option (Node.js 16.9+). This keeps the root failure for debugging while presenting a clean type to callers.
class DatabaseError extends AppError {
constructor(message, cause) {
super(message, 'DB_ERROR', { cause });
}
}
// AppError must forward options to super for `cause` to be set:
class AppError extends Error {
constructor(message, code, options) {
super(message, options);
this.name = this.constructor.name;
this.code = code;
}
}
try {
JSON.parse('{ invalid');
} catch (original) {
const err = new DatabaseError('Failed to load config', original);
console.log(err.message);
console.log(err.cause.constructor.name);
}
Output:
Failed to load config
SyntaxError
Custom errors and CommonJS
The pattern is identical under CommonJS — only the export syntax changes. Define the class normally and attach it to module.exports.
// errors.cjs
class AppError extends Error {
constructor(message, code) {
super(message);
this.name = this.constructor.name;
this.code = code;
}
}
module.exports = { AppError };
Custom error properties reference
| Property | Source | Purpose |
|---|---|---|
message | super(message) | Human-readable description |
name | set in constructor | Class label shown in logs / toString |
code | set in constructor | Stable, machine-readable identifier |
stack | V8 / captureStackTrace | Call site for debugging |
cause | super(msg, { cause }) | Underlying error that triggered this one |
custom (field, status) | set in constructor | Domain-specific context |
Best Practices
- Always call
super(message)first so the baseErrorsetsmessageandstackcorrectly. - Set
this.nameto the class name (usethis.constructor.name) so logs and stringified errors identify the type. - Give every error a stable
codeand prefer branching on it over fragile message-string matching. - Call
Error.captureStackTrace(this, this.constructor)to keep the constructor frame out of the trace. - Build a small hierarchy from a shared base class so one
instanceof AppErrorcheck catches all your application errors. - Forward the
{ cause }option tosuperand use it when rethrowing, so the original failure is never lost. - Avoid putting heavy logic in error constructors — they run on the failure path and should stay cheap and side-effect free.