Skip to main content

How to create a custom error type

A custom error type is an ordinary class that extends Error: in the constructor you call super(message, { cause }), set this.name and add your own fields such as code, status or details. Such a class behaves like a real error, keeps its stack and cause, and can be recognised precisely with instanceof.

Theory

TL;DR

  • class MyError extends Error plus super(message) is the minimum that already works.
  • this.name = this.constructor.name makes the error name meaningful in logs.
  • The second argument of super(message, { cause }) keeps the original error in the chain.
  • Error.captureStackTrace(this, this.constructor) (V8) strips the constructor out of the stack.
  • Custom fields (code, status, details) make the error fit for APIs and monitoring.
  • An AppError -> ValidationError / AuthError hierarchy gives precise handling in catch.

Quick example

javascript
class ValidationError extends Error { constructor(message, details) { super(message); this.name = "ValidationError"; this.details = details; } } try { throw new ValidationError("Email is required", { field: "email" }); } catch (e) { console.log(e instanceof ValidationError, e.name, e.details); // true ValidationError { field: "email" } }

The base template (ES2022+)

javascript
class AppError extends Error { constructor(message, { code, cause } = {}) { super(message, { cause }); // keeps stack and cause this.name = this.constructor.name; // "AppError" this.code = code ?? "APP_ERROR"; if (Error.captureStackTrace) { Error.captureStackTrace(this, this.constructor); } } } // Specific cases class ValidationError extends AppError { constructor(message, details, opts = {}) { super(message, { ...opts, code: "VALIDATION_ERROR" }); this.details = details; // any fields of your own } } class AuthError extends AppError { constructor(message = "Unauthorized", opts = {}) { super(message, { ...opts, code: "AUTH_ERROR" }); this.status = 401; } }

Usage:

javascript
function parseUser(input) { if (!input.email) { throw new ValidationError("Email is required", { field: "email" }); } return input; } try { parseUser({}); } catch (e) { if (e instanceof ValidationError) { console.log(e.code, e.details); // VALIDATION_ERROR { field: "email" } } else { console.error("Unexpected error", e); } }

Wrapping the original error with cause

This is useful when you intercept a low level exception and want to add context without losing the original:

javascript
try { JSON.parse("not valid JSON"); } catch (e) { throw new AppError("Failed to parse the config", { cause: e, code: "CONFIG_PARSE" }); }

Later you can inspect the whole chain:

javascript
console.error(err.cause); // the original error

The TypeScript version

typescript
type AppErrorCode = "APP_ERROR" | "VALIDATION_ERROR" | "AUTH_ERROR"; class AppError extends Error { code: AppErrorCode; declare cause?: unknown; // so that TS knows about cause constructor(message: string, opts: { code?: AppErrorCode; cause?: unknown } = {}) { super(message, { cause: opts.cause }); this.name = new.target.name; this.code = opts.code ?? "APP_ERROR"; if ((Error as any).captureStackTrace) { (Error as any).captureStackTrace(this, new.target); } } } class ValidationError extends AppError { details?: Record<string, unknown>; constructor(message: string, details?: Record<string, unknown>, opts: { cause?: unknown } = {}) { super(message, { ...opts, code: "VALIDATION_ERROR" }); this.details = details; } }

Here new.target.name yields the name of the class actually being constructed, so subclasses get the right name with no extra code.

A couple of practical techniques

  • Serialisation (logging or an API response):

    javascript
    function errorToJson(err) { return { name: err.name, message: err.message, code: err.code, stack: err.stack, details: err.details, cause: err.cause instanceof Error ? err.cause.message : err.cause, }; }
  • HTTP binding. Add a status (400, 401, 404, 500) to your errors and map them to responses at the middleware level.

  • Guarantee instanceof. Modern engines are fine with it, but the extends Error template together with captureStackTrace (where available) is a dependable base. If you compile down to ES5, you also need Object.setPrototypeOf(this, new.target.prototype).

Common mistakes

  • Forgetting super(message). Without it message comes out empty and the stack is truncated.
  • Not setting name. In logs and monitoring the error shows up as a plain Error, leaving you nothing to filter on.
  • Losing the original error. You catch a low level exception and throw a new one without cause, so the cause of the failure can no longer be recovered.
  • Relying on e.constructor.name instead of instanceof. Minification renames classes, while instanceof keeps working.
  • Counting on Error.captureStackTrace everywhere. It is a V8 specific API, so the call must always be guarded by a presence check.
  • Putting sensitive data into the error. The error object often lands in logs in full, so passwords and tokens do not belong in details.

Short Answer

Interview ready
Premium

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