Creating Custom Errors in JS: Complete Tutorial
Build your own error types by extending the Error class. Learn how custom errors make your code more readable, easier to debug, and safer to handle with instanceof checks.
A custom error is an error type you create by extending the built-in Error class. Instead of throwing new Error("something failed") everywhere, you define named error types like ValidationError, NetworkError, or TimeoutError that carry meaningful properties and can be identified with instanceof.
The benefit is precision. A catch block that receives a ValidationError knows exactly what happened and can respond appropriately, instead of guessing by parsing a message string. This matters most once an application has several failure modes that need different handling, such as a validation problem that should highlight a form field versus a network problem that should trigger a retry.
class ValidationError extends Error {
constructor(message, field) {
super(message);
this.name = "ValidationError";
this.field = field;
}
}
try {
throw new ValidationError("Email is required", "email");
} catch (error) {
console.log(`Field "${error.field}" is invalid: ${error.message}`);
// Field "email" is invalid: Email is required
}The class extends Error and adds a field property that a plain error could never carry. Checking error.field lets the catch block highlight the exact form input that failed, something a generic message string cannot do on its own.
Extending the Error class
The minimum custom error needs three things: a class that extends Error, a call to super(message) as the first line of the constructor, and setting this.name.
class DatabaseError extends Error {
constructor(message) {
super(message);
this.name = "DatabaseError";
}
}
const error = new DatabaseError("Connection refused on port 5432");
console.log(error.name); // "DatabaseError"
console.log(error instanceof DatabaseError); // true
console.log(error instanceof Error); // trueCalling super(message) is critical. The Error base constructor needs the message to populate error.message and generate the stack trace. Setting this.name to the class name is a convention rather than a requirement, but it helps whenever you log error.name or serialize errors for reporting.
Adding custom properties
Raw error messages are not enough for real applications. Add properties that your error handling code can act on programmatically instead of parsing text:
class ApiError extends Error {
constructor(message, statusCode, endpoint) {
super(message);
this.name = "ApiError";
this.statusCode = statusCode;
this.endpoint = endpoint;
}
}ApiError adds two fields a plain error cannot carry: statusCode and endpoint. A fetch helper can throw this type instead of a generic error whenever a response comes back with a failing status:
async function fetchData(url) {
const response = await fetch(url);
if (!response.ok) {
throw new ApiError(`Request failed with status ${response.status}`, response.status, url);
}
return response.json();
}The statusCode field lets a caller branch on the HTTP status without parsing the message, and the endpoint field tells you which URL failed:
try {
await fetchData("/api/users");
} catch (error) {
if (error instanceof ApiError && error.statusCode === 404) {
console.log("Resource not found at:", error.endpoint);
}
}Checking error instanceof ApiError first is what makes reading statusCode safe here. A plain network failure would not be an ApiError and would not have that property at all.
Chaining errors with the cause property
When you catch an error and wrap it in a new one, pass the original as the cause option. This preserves the full error chain for debugging instead of losing the original failure:
class ConfigError extends Error {
constructor(message, cause) {
super(message, { cause });
this.name = "ConfigError";
}
}The second argument to super() accepts an options object with a cause key. Catching a lower-level failure and wrapping it keeps both messages available on the new error:
try {
JSON.parse("invalid");
} catch (parseError) {
const configError = new ConfigError("Failed to parse config file", parseError);
console.log(configError.message); // "Failed to parse config file"
console.log(configError.cause.message); // "Unexpected token 'i', ..."
}The cause property is a standard part of the Error API since ES2022, supported in every modern browser and in Node.js 16.9 and later. When you send errors to a monitoring service, cause gives you the full story of what actually went wrong underneath the top-level message.
A real-world error hierarchy
In a larger application, you will want several custom error types that share common properties. Use a base class that every specific error extends:
class AppError extends Error {
constructor(message, code, cause) {
super(message, { cause });
this.name = "AppError";
this.code = code;
}
}Every specific error type extends AppError instead of Error directly, so they all pick up the code and cause fields for free:
class NotFoundError extends AppError {
constructor(resource, id, cause) {
super(`Resource ${resource} with id ${id} not found`, "NOT_FOUND", cause);
this.name = "NotFoundError";
this.resource = resource;
}
}PermissionError and RateLimitError follow the same shape as NotFoundError, each passing its own message and code up to AppError while adding the one or two fields specific to that failure:
class PermissionError extends AppError {
constructor(action, cause) {
super(`Permission denied: ${action}`, "PERMISSION_DENIED", cause);
this.name = "PermissionError";
}
}
class RateLimitError extends AppError {
constructor(retryAfterSeconds, cause) {
super(`Rate limit exceeded, retry after ${retryAfterSeconds}s`, "RATE_LIMITED", cause);
this.name = "RateLimitError";
this.retryAfterSeconds = retryAfterSeconds;
}
}Every custom error in this hierarchy is still an instance of Error, so a generic fallback handler works without changes anywhere in the chain. The code field also gives you a stable identifier you can log, search for, or match against in an alerting rule, since messages tend to change wording over time while a code like "NOT_FOUND" stays constant. Now the error handling itself gets clean and specific instead of relying on string matching:
try {
await performAction();
} catch (error) {
if (error instanceof RateLimitError) {
await delay(error.retryAfterSeconds * 1000);
return retry();
}
if (error instanceof NotFoundError) {
redirectTo("/404");
return;
}
reportError(error);
}This pattern replaces brittle string matching, such as checking whether a message includes the text "404", with type checks that stay correct even if the wording of a message changes later.
Common mistakes
Forgetting to call super() is the most common mistake. In a derived class, the constructor cannot read or write this until super() has run, so skipping it does not silently produce a broken error object, it throws immediately:
class BadError extends Error {
constructor(message) {
this.name = "BadError"; // ReferenceError: must call super first
this.details = message;
}
}
new BadError("fail"); // throws before the error is ever createdSetting this before calling super() throws the same reference error, just in the opposite order. The fix is identical either way: call super(message) as the very first line of the constructor, before touching this at all.
Not setting this.name is a quieter mistake. The error will work, but error.name will read as "Error" instead of your custom type name, which is misleading for anyone reading logs or a monitoring dashboard:
class UnnamedError extends Error {
constructor(message) {
super(message);
}
}
console.log(new UnnamedError("oops").name); // "Error", not "UnnamedError"Overcomplicating the hierarchy is the last common mistake. You do not need an error class for every possible failure.
Start with one or two custom types and add more only when you have a real need to distinguish them with instanceof in a catch block. If you never write that check for a type, you probably do not need the class.
Preserving stack traces in older environments
Modern JavaScript engines handle extends Error correctly out of the box. V8-based runtimes such as Chrome, Edge, and Node.js also expose a non-standard Error.captureStackTrace() method that trims the constructor itself out of the stack trace:
class LegacyError extends Error {
constructor(message) {
super(message);
this.name = "LegacyError";
if (Error.captureStackTrace) {
Error.captureStackTrace(this, LegacyError);
}
}
}Safari's JavaScriptCore engine does not implement captureStackTrace, which is why the code checks if (Error.captureStackTrace) before calling it. For most applications in 2026, you can skip this entirely: just extend Error, call super(message), and set this.name.
For more on using custom errors with try...catch, see the try...catch guide. For the full list of built-in error types you can extend from, see the JavaScript error types guide.
Rune AI
Key Insights
- Extend the Error class to create custom error types with meaningful names.
- Use instanceof in catch blocks to handle each error type appropriately.
- Add custom properties like error codes, field names, or HTTP statuses to make handling richer.
- Pass the original error as cause to preserve the full error chain.
- Always call super(message) first in the constructor, then set this.name.
Frequently Asked Questions
Why create custom errors instead of using new Error('message')?
Do custom error classes preserve the stack trace?
Can I add custom properties to my error class?
Conclusion
Custom error types turn a generic catch block into precise, readable error handling. By extending Error and adding domain-specific properties, you can distinguish between validation failures, network timeouts, and permission errors without parsing strings. Use the cause property to chain errors and preserve the full debugging context.
More in this topic
Using Decorators for Logging in JS Architecture
Learn how JavaScript decorators wrap class methods to add logging without touching the original code, including setup with Babel and how execution order works.
JavaScript While Loop Explained: A Complete Guide
A while loop repeats code as long as a condition stays true. Learn the syntax, see practical examples, and understand when a while loop is the right choice over a for loop.
JavaScript Loops Tutorial: for, while, do while
Loops let JavaScript repeat code without writing it twice. Learn the three core loop types, what each one does, and when to choose one over another.