Extending Error with custom properties
Extending Error means declaring your own class that inherits from Error, passing the message to the parent constructor via super(message) and adding your own properties to it. You then throw such errors like any others, but in catch you get a structure instead of a bare string: a code, an HTTP status, details and a cause.
Theory
TL;DR
- A custom error class is
class AppError extends Error, and the constructor must callsuper(message). - After
superyou can add any fields you like:code,status,details,context. this.name = this.constructor.namemakes the error name readable in logs and in the stack trace.Error.captureStackTrace(this, this.constructor)(V8) removes your own constructor from the stack trace.- Since ES2022 there is a standard
causeproperty that stores the original error:new Error(msg, { cause: err }). - A class hierarchy (
AppError->ValidationError,AuthError) gives convenientinstanceofchecks.
Quick example
class AppError extends Error {
constructor(message, code) {
super(message); // pass the message to the parent Error
this.name = "AppError"; // set the error name
this.code = code; // add our own property
}
}
try {
throw new AppError("Something went wrong", "SERVER_ERROR");
} catch (e) {
console.log(e.name); // AppError
console.log(e.message); // Something went wrong
console.log(e.code); // SERVER_ERROR
console.log(e.stack); // call stack
}Your error now has both the standard properties (message, stack) and the custom one (code).
The base class and the stack trace
The minimal contract of a custom error is three things: a super(message) call, a correct name and a clean stack trace.
class AppError extends Error {
constructor(message, code) {
super(message);
this.name = this.constructor.name; // the name comes from the class, not a literal
this.code = code;
Error.captureStackTrace?.(this, this.constructor);
}
}super(message)is mandatory: without itthisis unavailable, andmessageandstackstay unset.this.name = this.constructor.nameyields"AppError","ValidationError"and so on automatically, so you do not repeat a string literal in every subclass.Error.captureStackTraceexists in V8 (Node.js, Chrome). The second argument excludes the constructor frame, so the first line points at the place where the error was actually thrown. Calling it with?.()keeps the code safe in engines that lack the method.
Status, details and an error hierarchy
For the HTTP layer it is convenient to carry a status and structured details on the error itself:
class HttpError extends Error {
constructor(message, status = 500, details = {}) {
super(message);
this.name = this.constructor.name;
this.status = status;
this.details = details;
if (Error.captureStackTrace) {
Error.captureStackTrace(this, this.constructor);
}
}
}
try {
throw new HttpError("User not found", 404, { userId: 123 });
} catch (err) {
console.log(err.name); // HttpError
console.log(err.status); // 404
console.log(err.details); // { userId: 123 }
}Several such classes build a whole tree of errors:
class AppError extends Error {
constructor(message, code) {
super(message);
this.name = this.constructor.name;
this.code = code;
Error.captureStackTrace?.(this, this.constructor);
}
}
class ValidationError extends AppError {
constructor(message, field) {
super(message, "VALIDATION_ERROR");
this.field = field;
}
}
class AuthError extends AppError {
constructor(message = "Unauthorized") {
super(message, "AUTH_ERROR");
this.status = 401;
}
}Usage:
try {
throw new ValidationError("Invalid email", "email");
} catch (err) {
if (err instanceof ValidationError) {
console.log("Error in field:", err.field);
}
console.log(err.code); // VALIDATION_ERROR
}Because ValidationError inherits from AppError, the check err instanceof AppError is true as well. That gives you two levels of handling: a generic one for all of your own errors and a targeted one for a specific type.
The cause property (ES2022)
The modern ECMAScript standard supports a cause property that stores the original error which led to the current one. This way you do not lose the original stack trace when you wrap a low level error into your own domain error.
try {
JSON.parse("invalid JSON");
} catch (parseErr) {
throw new AppError("Failed to parse JSON", "PARSE_ERROR", { cause: parseErr });
}For that to work, the third argument has to be forwarded to super:
class AppError extends Error {
constructor(message, code, options = {}) {
super(message, options); // the engine sets this.cause itself
this.name = this.constructor.name;
this.code = code;
this.cause = options.cause; // compatibility with older engines
}
}Central handling in Express or NestJS
The main benefit of custom classes is that a single handler can answer correctly for any of your errors and hide internal details for everything else:
app.use((err, req, res, next) => {
if (err instanceof AppError) {
res.status(err.status || 500).json({
error: err.name,
message: err.message,
code: err.code,
details: err.details,
});
} else {
res.status(500).json({ error: "InternalError", message: "Unknown error" });
}
});Why extend Error at all
| Reason | Example |
|---|---|
| Clear, understandable messages | "Email is required" instead of "Unexpected token" |
| Structure and typing | ValidationError, AuthError, HttpError |
| Easier logging | code, status and details land in the log |
| Keeping the original cause | through cause |
| Easier debugging | different error types are easy to tell apart in catch |
The summary template:
class MyError extends Error {
constructor(message, customProp) {
super(message);
this.name = "MyError";
this.customProp = customProp;
Error.captureStackTrace?.(this, this.constructor);
}
}
throw new MyError("Database error", { query: "SELECT * FROM users" });Common mistakes
- Forgetting
super(message). The constructor throws aReferenceErroron the first use ofthis, and callingsuper()with no argument leavesmessageempty. - Not setting
name. Logs andstackthen show plainError, and your custom type becomes invisible. - Relying on
this.constructor.nameafter minification. A bundler may rename the class, so public error codes belong in a separatecodefield rather than inname. - Checking the type with
err.name === "AppError"instead ofinstanceof. A string is easy to break, andinstanceofalso sees the whole hierarchy. - Transpiling to ES5 without care. With an ES5 target,
class X extends Errorbreaks the prototype chain andinstanceofstops working. The fix isObject.setPrototypeOf(this, new.target.prototype)in the constructor, or an ES2015+ target. - Putting sensitive data on the error.
detailsoften ends up in logs and in the HTTP response, so passwords, tokens and personal data must not go there. - Assuming
Error.captureStackTraceexists everywhere. It is a non standard V8 extension, so make the call optional.
Short Answer
Interview readyA concise answer to help you respond confidently on this topic during an interview.