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 Errorplussuper(message)is the minimum that already works.this.name = this.constructor.namemakes 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/AuthErrorhierarchy gives precise handling incatch.
Quick example
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+)
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:
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:
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:
console.error(err.cause); // the original errorThe TypeScript version
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):
javascriptfunction 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 theextends Errortemplate together withcaptureStackTrace(where available) is a dependable base. If you compile down to ES5, you also needObject.setPrototypeOf(this, new.target.prototype).
Common mistakes
- Forgetting
super(message). Without itmessagecomes out empty and the stack is truncated. - Not setting
name. In logs and monitoring the error shows up as a plainError, 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.nameinstead ofinstanceof. Minification renames classes, whileinstanceofkeeps working. - Counting on
Error.captureStackTraceeverywhere. 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 readyA concise answer to help you respond confidently on this topic during an interview.