structuring domain errors instead of new Error("...") everywhere
The built in Error class from JavaScript is rarely enough for a growing codebase: it carries no error codes, no structured metadata, and no clean serialization. A well thought out error class hierarchy solves these problems with a shared base class, consistent instanceof checks, and cause chaining for connected failure causes.
Table of Contents
- 1. Why the built in Error class reaches its limits
- 2. Designing a base class for domain errors
- 3. Attaching error codes and metadata type safely
- 4. Handling instanceof checks and the prototype chain correctly
- 5. Error hierarchies for different error categories
- 6. Error serialization for logging and API responses
- 7. Chaining error classes with cause chaining
- 8. Common mistakes when building error hierarchies
- 9. Approaches in comparison
- 10. Summary
- 11. FAQ
1. Why the built in Error class reaches its limits
The native Error class in JavaScript is deliberately minimal: it carries a message, a name, and, depending on the environment, a stack property. That is enough for simple scripts, but once an application grows, exactly the information a structured error handling approach needs starts to be missing: a machine readable error code, additional context data such as an affected entity id, and a clear indication of which layer of the application the error came from. A custom error class hierarchy closes exactly that gap.
Without such an error class hierarchy, many teams resort to string comparisons like error.message.includes("not found") to distinguish error kinds. This approach is fragile, because message texts can silently change during a translation or a minor wording adjustment, breaking the comparison unnoticed. A dedicated class per error kind, recognizable via instanceof, is robust against such text changes, because identity runs through the type rather than the message text.
The third reason for a custom error class hierarchy is consistency across an entire team. Without a shared base class, every developer builds their own ad hoc conventions for error objects, leading to a wildly growing variety of shapes that can neither be logged uniformly nor returned to the client uniformly. A shared base class with clear conventions for error codes, metadata, and serialization brings order here.
2. Designing a base class for domain errors
The first building block of every error class hierarchy is a shared base class, often called DomainError or AppError, from which all more specific error classes inherit. This base class extends the native Error class and adds fields that should be consistently available across the entire application: a code for machine readable identification, an isOperational flag distinguishing expected errors from genuine programming bugs, and optional structured metadata.
An important design decision is whether DomainError should be abstract. In TypeScript, an abstract class prevents developers from accidentally instantiating new DomainError(...) directly instead of using one of the more specific subclasses. This restriction forces every error that is actually thrown to belong to a concrete, named category, which pays off later both for logging and for error handling at the call site.
In the base class constructor, one detail matters especially: Object.setPrototypeOf(this, new.target.prototype) has to be set after the super() call whenever the class inherits from Error across several levels and runs in an environment that does not fully transpile Error to ES6 semantics internally, such as older TypeScript target settings like ES5. Without this fix, instanceof checks on derived classes do not work reliably in such environments.
// Base class for the entire domain error hierarchy
abstract class DomainError extends Error {
abstract readonly code: string;
readonly isOperational: boolean;
readonly timestamp: Date;
constructor(message: string, isOperational = true) {
super(message);
this.name = new.target.name;
this.isOperational = isOperational;
this.timestamp = new Date();
// Required for correct instanceof checks when targeting ES5
Object.setPrototypeOf(this, new.target.prototype);
// Node.js: excludes the constructor call itself from the stack trace
if (Error.captureStackTrace) {
Error.captureStackTrace(this, new.target);
}
}
}
class NotFoundError extends DomainError {
readonly code = "NOT_FOUND";
constructor(readonly resourceType: string, readonly resourceId: string) {
super(`${resourceType} with id ${resourceId} was not found`);
}
}
const error = new NotFoundError("Product", "sku-42");
console.log(error instanceof DomainError); // true
console.log(error instanceof Error); // true
console.log(error.code); // "NOT_FOUND"
3. Attaching error codes and metadata type safely
A plain code: string is enough for many cases, but type safety improves noticeably once code gets declared as a literal type instead of a generic string. If every concrete error class in the error class hierarchy carries a fixed, literally typed code, a union of every possible code can be derived automatically, enabling autocomplete and exhaustiveness checking during error handling.
For additional metadata, such as which fields are affected by a validation error, a generic approach in the base class pays off: an optional details field whose concrete type is determined by the respective subclass. That way, ValidationError can carry details: { field: string; constraint: string }[], while RateLimitError instead carries details: { retryAfterSeconds: number }, each matching its concrete error kind.
A commonly used pattern in production ready codebases is to additionally give every error class a static httpStatus property, determining which HTTP status code should be returned when the error gets translated into a response at the API boundary. Coupling this at the source, rather than in a separate mapping table somewhere in the code, reduces the chance that a new error class gets forgotten when the mapping needs to be maintained.
// Error codes as literal types, plus structured metadata per subclass
abstract class DomainError extends Error {
abstract readonly code: string;
abstract readonly httpStatus: number;
}
class ValidationError extends DomainError {
readonly code = "VALIDATION_ERROR" as const;
readonly httpStatus = 422;
constructor(readonly details: Array<{ field: string; constraint: string }>) {
super(`Validation failed for ${details.length} field(s)`);
}
}
class RateLimitError extends DomainError {
readonly code = "RATE_LIMIT_EXCEEDED" as const;
readonly httpStatus = 429;
constructor(readonly retryAfterSeconds: number) {
super(`Rate limit exceeded, retry after ${retryAfterSeconds}s`);
}
}
// Union of all known error codes, derived from the concrete classes
type KnownErrorCode = ValidationError["code"] | RateLimitError["code"];
function logErrorCode(code: KnownErrorCode): void {
console.log(`Handling known error code: ${code}`);
}
4. Handling instanceof checks and the prototype chain correctly
instanceof is the central mechanism for distinguishing an error class hierarchy at runtime, but it has several pitfalls, especially in mixed build environments. The most common pitfall is a TypeScript target below ES2015, where the generated JavaScript code does not correctly build the prototype chain for classes that inherit from Error. The Object.setPrototypeOf pattern shown in the previous section fixes exactly this problem.
A second pitfall involves duplicate modules: if an application accidentally loads two separate copies of the same error class through a bundler or a monorepo setup, for example because two different versions of a library exist simultaneously in the node_modules tree, instanceof fails even though the error is content wise exactly the same class. This problem is not TypeScript specific, but a general JavaScript module quirk that shows up especially often in TypeScript projects with complex monorepo structures.
As a more robust alternative to plain instanceof, some teams additionally rely on a combination of instanceof and a check of the code field: error instanceof DomainError && error.code === "NOT_FOUND". This double check remains correct even if instanceof fails due to a duplicate module problem, as long as code stays identical across the module boundary as a plain data value.
// Robust checking: instanceof combined with the discriminating code field
function isKnownDomainError(caught: unknown): caught is DomainError {
return (
caught instanceof DomainError ||
// Fallback for duplicate module instances: check shape instead of identity
(typeof caught === "object" &&
caught !== null &&
"code" in caught &&
"isOperational" in caught)
);
}
function handleCaughtError(caught: unknown): void {
if (caught instanceof NotFoundError) {
console.log(`Not found: ${caught.resourceType}/${caught.resourceId}`);
return;
}
if (isKnownDomainError(caught)) {
console.log(`Known domain error: ${caught.code}`);
return;
}
console.error("Unexpected error", caught);
throw caught; // re-throw truly unknown errors, do not swallow them
}
5. Error hierarchies for different error categories
A flat list of twenty classes inheriting directly from DomainError quickly becomes unwieldy. A multi level error class hierarchy works better, where a middle layer introduces broad categories such as a ValidationError base, a NotFoundError base, an AuthorizationError base, and an ExternalServiceError base, from which concrete, specific error classes then inherit. This intermediate layer lets code react to an entire category with a single instanceof check, without enumerating every concrete subclass individually.
A practical example: a global Express error handler can react uniformly to HTTP 401 or 403 with if (error instanceof AuthorizationError), regardless of whether it is a MissingTokenError, an ExpiredTokenError, or an InsufficientRoleError. Each of these concrete classes can still carry its own specific metadata, used in a more detailed log entry or a more specific client error message, without breaking the broad categorization inside the handler.
The depth of the hierarchy should follow actual behavior, not purely conceptual similarity. Two error classes should only share a common intermediate class when code genuinely exists that wants to treat both error kinds identically. An overly deep, purely taxonomic hierarchy without real behavioral grounding tends to hurt maintainability rather than improve it.
// Intermediate category classes group related, specific error classes
abstract class AuthorizationError extends DomainError {
readonly httpStatus = 403;
}
class MissingTokenError extends AuthorizationError {
readonly code = "MISSING_TOKEN" as const;
constructor() {
super("No authentication token was provided");
}
}
class InsufficientRoleError extends AuthorizationError {
readonly code = "INSUFFICIENT_ROLE" as const;
constructor(readonly requiredRole: string) {
super(`Role "${requiredRole}" is required for this operation`);
}
}
// A single check handles the whole category, regardless of the concrete class
function expressErrorHandler(error: unknown): { status: number; body: object } {
if (error instanceof AuthorizationError) {
return { status: error.httpStatus, body: { code: error.code, message: error.message } };
}
if (error instanceof DomainError) {
return { status: error.httpStatus, body: { code: error.code, message: error.message } };
}
return { status: 500, body: { code: "INTERNAL_ERROR", message: "Unexpected error" } };
}
6. Error serialization for logging and API responses
An error class hierarchy that only lives internally within one process is only half the solution. As soon as an error needs to be logged, sent to a log aggregator such as Elasticsearch, or returned to a client as JSON, it becomes clear that JSON.stringify(error) on a native Error instance produces an empty object {} by default, because message and stack are defined as non enumerable properties. A well thought out error class hierarchy has to solve this serialization explicitly.
The usual solution is a toJSON() method on the base class that explicitly copies all relevant fields into an enumerated object: code, message, timestamp, and optionally details. The stack trace should deliberately not end up in client responses by default in production environments, since it exposes internal file paths and code structure, but it absolutely should end up in internal log entries visible only to the own team.
For structured logging frameworks such as Pino or Winston, it pays off to additionally define a dedicated toLogObject() method that carries more detail than toJSON(), such as the full stack trace and internal debug information that should never reach the client. This separation between client safe and internally complete serialization is a central building block of every production ready error class hierarchy.
// Explicit serialization: safe for clients, richer for internal logs
abstract class DomainError extends Error {
abstract readonly code: string;
abstract readonly httpStatus: number;
readonly timestamp = new Date();
toJSON(): Record<string, unknown> {
return {
code: this.code,
message: this.message,
timestamp: this.timestamp.toISOString(),
};
}
toLogObject(): Record<string, unknown> {
return {
...this.toJSON(),
name: this.name,
stack: this.stack,
};
}
}
const error = new NotFoundError("Product", "sku-42");
console.log(JSON.stringify(error)); // uses toJSON automatically
logger.error(error.toLogObject());
const logger = { error: (_payload: Record<string, unknown>): void => {} };
7. Chaining error classes with cause chaining
In multi layer applications, an error often originates in a deep layer, such as a database driver, but gets translated into a domain specific error class in a higher layer. Without cause chaining, the original, technical failure cause gets lost in that process, making debugging considerably harder. Since ES2022, the native Error class supports a cause field in the constructor options object, which TypeScript fully types starting from version 4.6.
The cause field allows throwing a domain specific error class that references the original, technical error as its cause, without losing the failure information from the lower layer. When logging, the entire chain from error through error.cause down to the actual root cause can then be printed recursively, which is especially valuable for database errors, network errors, or errors inside third party libraries.
An important design aspect: the error class hierarchy should consistently support cause in the base class, not just in individual subclasses, so every derived error class automatically benefits from this mechanism. A small utility function that recursively translates the entire cause chain into an array of log objects makes this information directly usable for structured logging.
// Cause chaining preserves the original low-level error
class DatabaseConnectionError extends DomainError {
readonly code = "DB_CONNECTION_ERROR" as const;
readonly httpStatus = 503;
}
async function loadUserProfile(userId: string): Promise<unknown> {
try {
return await queryDatabase(userId);
} catch (rawError) {
// The original driver error is preserved via the cause option
throw new DatabaseConnectionError("Failed to load user profile", { cause: rawError });
}
}
function collectCauseChain(error: unknown): string[] {
const messages: string[] = [];
let current: unknown = error;
while (current instanceof Error) {
messages.push(current.message);
current = current.cause;
}
return messages;
}
async function queryDatabase(_userId: string): Promise<unknown> {
return {};
}
8. Common mistakes when building error hierarchies
The most common mistake is forgetting Object.setPrototypeOf, or a modern TypeScript target, causing instanceof checks on derived classes to silently fail. This bug often shows up late, because the error class works correctly in simple tests but suddenly stops being recognized in a production build pipeline with an older compile target.
// WRONG: missing prototype fix breaks instanceof on older compile targets
class BrokenError extends Error {
constructor(message: string) {
super(message);
// Missing: Object.setPrototypeOf(this, BrokenError.prototype);
}
}
// WRONG: swallowing the cause instead of chaining it
async function badFetch(): Promise<void> {
try {
await externalCall();
} catch {
throw new Error("External call failed"); // original cause is lost
}
}
// RIGHT: always fix the prototype chain and preserve the cause
class FixedError extends Error {
constructor(message: string) {
super(message);
Object.setPrototypeOf(this, FixedError.prototype);
}
}
async function goodFetch(): Promise<void> {
try {
await externalCall();
} catch (rawError) {
throw new Error("External call failed", { cause: rawError });
}
}
async function externalCall(): Promise<void> {}
A second widespread mistake is a hierarchy that stays too flat without categorization, where every error class inherits directly from Error without a shared base class. That forces every consuming site in the code to import and check every concrete error class individually, instead of catching an entire category with a single instanceof DomainError check. A third mistake is leaving out isOperational or a similar flag entirely, which makes it impossible to distinguish genuine programming bugs from expected business logic errors, something that matters especially for crash reporting and process monitoring.
9. Approaches in comparison
The table below compares different approaches to modeling error classes.
| Criterion | new Error(message) | Flat Custom Classes | Error Class Hierarchy |
|---|---|---|---|
| Machine readable codes | No | Yes, per class | Yes, consistent via base class |
| Categorized instanceof checks | No | No, each class separately | Yes, via intermediate classes |
| Serialization for logs/API | Manual work needed | Inconsistent per class | Consistent via toJSON |
| Cause chaining | Possible manually | Possible manually | Standardized in base class |
| Maintainability in a team | Low | Moderate | High |
For small scripts, new Error(message) is enough. Once several developers work on different error kinds and these need to be logged, categorized, and returned to clients consistently, the upfront effort of a full error class hierarchy pays off noticeably.
Mironsoft
Backend architecture, logging, and error handling for Node.js
An error hierarchy your entire team actually understands?
We design a complete error class hierarchy with error codes, serialization, and cause chaining for your Node.js and TypeScript backends.
Hierarchy Design
Designing a base class, categories, and error codes for your backend
Logging Integration
Structured serialization for Pino, Winston, and Elasticsearch
Migration
Gradually migrating existing new Error calls onto the hierarchy
10. Summary
A well structured error class hierarchy replaces scattered new Error("...") calls with a shared base class carrying error codes, structured metadata, and consistent serialization. An abstract base class prevents accidental direct instantiation, while Object.setPrototypeOf and modern compile targets ensure instanceof checks on derived classes work reliably.
Intermediate layers for categories such as AuthorizationError allow broad error handling without enumerating every concrete subclass individually. toJSON() and toLogObject() separate client safe from internally complete serialization, and cause chaining preserves the original technical failure cause across several layers. Together this produces an error class hierarchy that noticeably eases both debugging and error handling across the entire team.
Error Class Hierarchies in TypeScript, the essentials at a glance
Base Class
An abstract DomainError class with error code, isOperational flag, and timestamp for every error kind.
instanceof Safety
Object.setPrototypeOf after super() guarantees correct instanceof checks even on older compile targets.
Serialization
toJSON for client responses, toLogObject with the full stack trace for internal logs.
Cause Chaining
The native cause field preserves the original technical failure cause across several layers.