TL;DR
serialize-error converts Error objects into plain JSON-safe objects and back — essential for logging, API responses, and sending errors across process boundaries. VError provides error chaining ("caused by") and informational properties — trace the full chain of failures from surface error to root cause. AggregateError is a built-in JavaScript class that wraps multiple errors into one — used by Promise.any() and useful for batch operations. In 2026: use serialize-error for logging/APIs, native Error.cause (ES2022) instead of VError for most chaining needs, and AggregateError for multi-error scenarios.
Key Takeaways
- serialize-error: ~8M weekly downloads — converts Error → plain object → Error, preserves stack traces
- VError: ~30M weekly downloads — error chaining, multi-error, sprintf-style messages (widely depended on)
- AggregateError: built-in (ES2021) — wraps multiple errors, used by
Promise.any() - ES2022
Error.causereplaces most VError use cases —new Error("msg", { cause: originalError }) - Errors are NOT JSON-serializable by default —
JSON.stringify(new Error("x"))returns{} - serialize-error handles circular references, custom properties, and nested errors
The Problem: Errors Don't Serialize
const error = new Error("Database connection failed")
error.code = "ECONNREFUSED"
error.port = 5432
// ❌ JSON.stringify ignores non-enumerable properties:
console.log(JSON.stringify(error))
// → "{}" (message, stack, name are non-enumerable)
// ❌ Object spread loses the prototype:
console.log({ ...error })
// → { code: "ECONNREFUSED", port: 5432 } (no message, no stack!)
// ✅ serialize-error preserves everything:
import { serializeError } from "serialize-error"
console.log(JSON.stringify(serializeError(error)))
// → { "name": "Error", "message": "Database connection failed",
// "stack": "Error: Database...", "code": "ECONNREFUSED", "port": 5432 }
serialize-error
serialize-error — Error ↔ plain object:
Serialize for logging
import { serializeError, deserializeError } from "serialize-error"
try {
await db.query("SELECT * FROM packages WHERE name = $1", ["react"])
} catch (error) {
// Serialize for structured logging (pino, winston):
logger.error({
msg: "Database query failed",
error: serializeError(error),
// → { name, message, stack, code, ... } as plain object
})
// Serialize for API response:
res.status(500).json({
error: serializeError(error),
})
}
Preserve custom properties
import { serializeError } from "serialize-error"
class ApiError extends Error {
statusCode: number
requestId: string
constructor(message: string, statusCode: number, requestId: string) {
super(message)
this.name = "ApiError"
this.statusCode = statusCode
this.requestId = requestId
}
}
const error = new ApiError("Rate limited", 429, "req_abc123")
const serialized = serializeError(error)
// → {
// name: "ApiError",
// message: "Rate limited",
// stack: "ApiError: Rate limited\n at ...",
// statusCode: 429,
// requestId: "req_abc123"
// }
Deserialize (reconstruct from JSON)
import { serializeError, deserializeError } from "serialize-error"
// Worker thread or subprocess sends serialized error:
const serialized = JSON.parse(workerMessage)
// Reconstruct into a real Error:
const error = deserializeError(serialized)
console.log(error instanceof Error) // true
console.log(error.message) // "Rate limited"
console.log(error.stack) // Original stack trace preserved
Handle circular references
import { serializeError } from "serialize-error"
// Errors sometimes have circular references:
const error = new Error("circular")
error.self = error // Circular!
// JSON.stringify would throw
// serialize-error handles it gracefully:
const safe = serializeError(error)
// → { name: "Error", message: "circular", self: "[Circular]" }
Error cause chain
import { serializeError } from "serialize-error"
// ES2022 Error.cause:
const dbError = new Error("ECONNREFUSED")
const serviceError = new Error("Failed to fetch package", { cause: dbError })
const apiError = new Error("Internal server error", { cause: serviceError })
const serialized = serializeError(apiError)
// → {
// name: "Error",
// message: "Internal server error",
// cause: {
// name: "Error",
// message: "Failed to fetch package",
// cause: {
// name: "Error",
// message: "ECONNREFUSED"
// }
// }
// }
VError (and Error.cause)
VError — error chaining library:
VError chaining
import VError from "verror"
function connectToDatabase(): never {
throw new Error("ECONNREFUSED: 127.0.0.1:5432")
}
function fetchPackageData(name: string): never {
try {
connectToDatabase()
} catch (error) {
throw new VError(error, "failed to fetch package %s", name)
// Message: "failed to fetch package react: ECONNREFUSED: 127.0.0.1:5432"
}
}
function handleRequest(): void {
try {
fetchPackageData("react")
} catch (error) {
throw new VError(error, "API request failed")
// Message: "API request failed: failed to fetch package react: ECONNREFUSED..."
}
}
// Full chain in one message:
// "API request failed: failed to fetch package react: ECONNREFUSED: 127.0.0.1:5432"
VError info properties
import VError from "verror"
throw new VError({
name: "PackageFetchError",
cause: originalError,
info: {
packageName: "react",
endpoint: "https://registry.npmjs.org/react",
retryCount: 3,
},
}, "failed to fetch package data")
// Access structured info:
const info = VError.info(error)
// → { packageName: "react", endpoint: "...", retryCount: 3 }
Modern alternative: Error.cause (ES2022)
// ES2022 Error.cause replaces most VError use cases:
function connectToDatabase(): never {
throw new Error("ECONNREFUSED: 127.0.0.1:5432")
}
function fetchPackageData(name: string): never {
try {
connectToDatabase()
} catch (error) {
throw new Error(`Failed to fetch package: ${name}`, { cause: error })
}
}
function handleRequest(): void {
try {
fetchPackageData("react")
} catch (error) {
// Walk the cause chain:
let current = error
while (current) {
console.error(current.message)
current = current.cause
}
// "Failed to fetch package: react"
// "ECONNREFUSED: 127.0.0.1:5432"
}
}
Custom error classes with cause
// Modern TypeScript error classes with cause:
class AppError extends Error {
readonly statusCode: number
readonly isOperational: boolean
constructor(
message: string,
statusCode: number,
options?: { cause?: Error; isOperational?: boolean }
) {
super(message, { cause: options?.cause })
this.name = "AppError"
this.statusCode = statusCode
this.isOperational = options?.isOperational ?? true
}
}
class NotFoundError extends AppError {
constructor(resource: string, id: string, cause?: Error) {
super(`${resource} not found: ${id}`, 404, { cause })
}
}
class DatabaseError extends AppError {
constructor(operation: string, cause: Error) {
super(`Database ${operation} failed`, 500, { cause, isOperational: false })
}
}
// Usage:
try {
const pkg = await db.packages.findFirst({ where: { name } })
if (!pkg) throw new NotFoundError("Package", name)
return pkg
} catch (error) {
if (error instanceof NotFoundError) throw error
throw new DatabaseError("findFirst", error as Error)
}
AggregateError
AggregateError — built-in multi-error:
Promise.any() (built-in usage)
// Promise.any rejects with AggregateError when ALL promises reject:
try {
const data = await Promise.any([
fetch("https://registry.npmjs.org/react"),
fetch("https://mirror1.npmjs.org/react"),
fetch("https://mirror2.npmjs.org/react"),
])
} catch (error) {
if (error instanceof AggregateError) {
console.log(error.message) // "All promises were rejected"
console.log(error.errors) // [Error, Error, Error]
error.errors.forEach((e, i) => {
console.log(`Mirror ${i} failed: ${e.message}`)
})
}
}
Batch operations
// Collect errors from batch processing:
async function updatePackages(names: string[]): Promise<{
succeeded: string[]
failed: Array<{ name: string; error: Error }>
}> {
const succeeded: string[] = []
const errors: Error[] = []
const details: Array<{ name: string; error: Error }> = []
await Promise.allSettled(
names.map(async (name) => {
try {
await updatePackageData(name)
succeeded.push(name)
} catch (error) {
const err = error as Error
errors.push(err)
details.push({ name, error: err })
}
})
)
if (errors.length > 0) {
const aggregate = new AggregateError(
errors,
`${errors.length}/${names.length} package updates failed`
)
console.error(aggregate.message)
// "3/50 package updates failed"
// aggregate.errors → [Error, Error, Error]
}
return { succeeded, failed: details }
}
Validation errors
// Collect multiple validation errors:
function validatePackageInput(input: unknown): PackageInput {
const errors: Error[] = []
if (!input.name || input.name.length < 2) {
errors.push(new Error("Package name must be at least 2 characters"))
}
if (input.name && !/^[a-z0-9@/_-]+$/.test(input.name)) {
errors.push(new Error("Package name contains invalid characters"))
}
if (input.version && !semver.valid(input.version)) {
errors.push(new Error(`Invalid semver version: ${input.version}`))
}
if (errors.length > 0) {
throw new AggregateError(errors, "Package validation failed")
}
return input as PackageInput
}
// Catch and format:
try {
validatePackageInput({ name: "", version: "not-semver" })
} catch (error) {
if (error instanceof AggregateError) {
const messages = error.errors.map((e) => e.message)
res.status(400).json({ errors: messages })
// → { errors: ["Package name must be...", "Invalid semver..."] }
}
}
Feature Comparison
| Feature | serialize-error | VError | AggregateError | Error.cause |
|---|---|---|---|---|
| Serialize to JSON | ✅ | ❌ | ❌ | ❌ |
| Error chaining | ❌ | ✅ | ❌ | ✅ (native) |
| Multi-error | ❌ | ✅ (MultiError) | ✅ (native) | ❌ |
| sprintf messages | ❌ | ✅ | ❌ | ❌ |
| Info properties | ❌ | ✅ | ❌ | ❌ |
| Circular ref handling | ✅ | ❌ | ❌ | ❌ |
| Built-in (no install) | ❌ | ❌ | ✅ | ✅ |
| Weekly downloads | ~8M | ~30M | built-in | built-in |
Recommended Error Strategy for 2026
// 1. Custom error classes with Error.cause (replaces VError):
class AppError extends Error {
constructor(message: string, readonly statusCode: number, options?: ErrorOptions) {
super(message, options)
this.name = this.constructor.name
}
}
// 2. AggregateError for batch/validation (built-in):
throw new AggregateError(errors, "Batch operation failed")
// 3. serialize-error for logging and API responses:
import { serializeError } from "serialize-error"
logger.error({ error: serializeError(err) })
// This combination covers 99% of error handling needs
// with one small dependency (serialize-error) + two native features.
When to Use Each
Use serialize-error when:
- Logging errors to structured logging systems (pino, Datadog, Loki)
- Returning error details in API responses
- Sending errors between processes (worker threads, IPC)
- Need to handle circular references in error objects
Use VError when:
- Legacy codebase already depends on it (30M+ downloads = deep dependency tree)
- Need sprintf-style error message formatting
- Need structured
infoproperties on errors (though custom error classes work too) - Note: for new code, prefer
Error.cause(ES2022 native)
Use AggregateError when:
- Batch operations where multiple items can fail independently
- Validation that reports all errors at once (not just the first)
Promise.any()rejection handling- Any scenario where collecting multiple errors is better than failing on the first
TypeScript Custom Error Class Patterns
Designing custom error classes in TypeScript requires attention to a subtle prototype chain issue. When you extend the built-in Error class and transpile with older TypeScript targets, instanceof checks can fail because the prototype is not set correctly. The fix is to explicitly set the prototype in the constructor: Object.setPrototypeOf(this, new.target.prototype). In TypeScript targeting ES2022 or later (which is the norm in 2026), this is handled correctly by the runtime and no workaround is needed. A practical pattern is a base AppError class that all application errors extend, with a numeric statusCode for HTTP responses and a boolean isOperational flag distinguishing expected errors (404, validation failures) from unexpected crashes (database connection failures). This flag lets your global error handler decide whether to restart the process or simply log and continue.
Structured Logging Integration
The serialize-error library's primary value becomes clear when integrating with structured logging systems. pino, winston, and Datadog's Node.js agent all accept plain JavaScript objects for log entries. Without serialize-error, logging an Error object produces {} in JSON output because Error properties are non-enumerable. With serialize-error, the full error object — including message, stack trace, and all custom properties — appears in your log management system exactly as structured data you can query and alert on. For distributed tracing, attach a requestId or correlationId to your errors at the point of capture, and serialize-error will preserve these custom properties in the log output. This enables tracing a single request's error chain across multiple log entries in tools like Datadog, Grafana Loki, or the ELK stack.
Error Boundaries and Process-Level Error Handling
Beyond individual try-catch blocks, production Node.js applications need process-level error handling for uncaught exceptions and unhandled promise rejections. The process.on('uncaughtException', handler) and process.on('unhandledRejection', handler) callbacks receive raw Error objects that need serialization before they can be logged meaningfully. serialize-error is the right tool here — wrap the error before sending it to your logging service, capture the full error chain including any .cause properties, and then decide whether to gracefully restart the process. For worker thread communication, errors thrown in workers cannot cross the thread boundary as Error instances — they arrive as plain objects in the main thread. deserializeError reconstructs them into proper Error instances with working instanceof checks, enabling consistent error handling across threading boundaries.
Migration from VError to Native Error.cause
VError was essential before ES2022 added the cause option to the Error constructor, but in 2026 the built-in Error.cause covers the core use case. The migration path is straightforward for new errors: replace new VError(cause, message) with new Error(message, { cause }). Custom error classes that extend VError require more thought — VError's info property (for attaching structured metadata to errors) has no direct native equivalent. The recommended alternative is adding properties directly to your custom error class rather than using VError's info bag: class PackageError extends Error { packageName: string }. For codebases with deep VError usage, a pragmatic approach is continuing to use VError for existing code while adopting native Error.cause for new code — the two approaches interoperate since VError stores the cause in a way serialize-error can traverse.
AggregateError in Real-World Validation Libraries
AggregateError has influenced how modern validation libraries report errors. Zod throws a ZodError containing an array of ZodIssue objects — conceptually similar to AggregateError but with richer structure. When integrating Zod with AggregateError, you can wrap the ZodError.errors array: new AggregateError(zod.errors.map(e => new Error(e.message)), "Validation failed"). This lets code that handles AggregateError (like batch processing error collectors) receive Zod validation failures uniformly. The Promise.any() pattern is increasingly common in resilience patterns — when you have multiple data sources (primary database, read replica, cache), Promise.any() returns the first successful result and AggregateError tells you why all failed if every source is unavailable. Understanding AggregateError's structure is essential for building resilient multi-source data fetching.
Choosing the Right Error Strategy for Your Project
The recommended error strategy for a new TypeScript project in 2026 combines native language features with one small library. Use custom error classes extending the native Error with Error.cause for error chaining — this covers the VError use case without the dependency. Use AggregateError for batch operations and validation where multiple independent errors need collecting — this is built-in with no install. Add serialize-error as your single error handling library dependency for structured logging and API error serialization. The total added dependency cost is one package (serialize-error) with zero transitive dependencies, and you get a complete, production-ready error handling system. Resist the temptation to add VError to new projects — its 30M weekly downloads reflect how deeply it's embedded in legacy dependency chains, not a recommendation for new code. Error.cause covers the essential chaining use case cleanly, and the TypeScript community has broadly adopted it as the standard pattern.
HTTP API Error Response Design
How errors are serialized for API consumers matters as much as internal error handling. The HTTP problem details spec (RFC 7807) defines a standard JSON error format that many API consumers expect: a type URI identifying the error class, a title human-readable summary, a status HTTP status code, a detail error message, and optional extension fields. serialize-error's output is not RFC 7807 compliant out of the box, but you can transform it — map error.name to type, error.message to detail, and add the appropriate status from your HTTP context. For public APIs where consumers will build error handling around your responses, adopting RFC 7807 provides a standard that clients can handle generically. For internal APIs where you control both client and server, a simpler format like { error: serializeError(err), requestId } is adequate and easier to work with in TypeScript.
Compare error handling and utility packages on PkgPulse →
See also: pm2 vs node:cluster vs tsx watch and h3 vs polka vs koa 2026, better-sqlite3 vs libsql vs sql.js.