Skip to main content

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 call super(message).
  • After super you can add any fields you like: code, status, details, context.
  • this.name = this.constructor.name makes 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 cause property that stores the original error: new Error(msg, { cause: err }).
  • A class hierarchy (AppError -> ValidationError, AuthError) gives convenient instanceof checks.

Quick example

javascript
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.

javascript
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 it this is unavailable, and message and stack stay unset.
  • this.name = this.constructor.name yields "AppError", "ValidationError" and so on automatically, so you do not repeat a string literal in every subclass.
  • Error.captureStackTrace exists 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:

javascript
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:

javascript
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:

javascript
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.

javascript
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:

javascript
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:

javascript
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

ReasonExample
Clear, understandable messages"Email is required" instead of "Unexpected token"
Structure and typingValidationError, AuthError, HttpError
Easier loggingcode, status and details land in the log
Keeping the original causethrough cause
Easier debuggingdifferent error types are easy to tell apart in catch

The summary template:

javascript
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 a ReferenceError on the first use of this, and calling super() with no argument leaves message empty.
  • Not setting name. Logs and stack then show plain Error, and your custom type becomes invisible.
  • Relying on this.constructor.name after minification. A bundler may rename the class, so public error codes belong in a separate code field rather than in name.
  • Checking the type with err.name === "AppError" instead of instanceof. A string is easy to break, and instanceof also sees the whole hierarchy.
  • Transpiling to ES5 without care. With an ES5 target, class X extends Error breaks the prototype chain and instanceof stops working. The fix is Object.setPrototypeOf(this, new.target.prototype) in the constructor, or an ES2015+ target.
  • Putting sensitive data on the error. details often ends up in logs and in the HTTP response, so passwords, tokens and personal data must not go there.
  • Assuming Error.captureStackTrace exists everywhere. It is a non standard V8 extension, so make the call optional.

Short Answer

Interview ready
Premium

A concise answer to help you respond confidently on this topic during an interview.