Skip to main content

serialize-error vs VError vs AggregateError: Error Handling in Node.js (2026)

·PkgPulse Team

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.cause replaces 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

Featureserialize-errorVErrorAggregateErrorError.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~30Mbuilt-inbuilt-in

// 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 info properties 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.

Compare error handling and utility packages on PkgPulse →

Comments

Stay Updated

Get the latest package insights, npm trends, and tooling tips delivered to your inbox.