serialize-error vs VError vs AggregateError: Error Handling in Node.js (2026)
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
Methodology
Download data from npm registry (weekly average, February 2026). Feature comparison based on serialize-error v11.x, verror v1.x, and native AggregateError/Error.cause.