NodeJSCustom Error Classes

Custom Error Classes

Throwing a plain new Error('User not found') forces the error handler to parse a string to decide what HTTP status to send. Custom error classes carry that information as structured data — statusCode, code, details — so the centralised handler just reads properties. They also make instanceof checks meaningful: you can catch a ValidationError specifically without touching NotFoundError. This page shows the pattern, the base class, and a practical set of subtypes.

The base HttpError class

errors/HttpError.js

JS
export class HttpError extends Error {
  constructor(statusCode, message, code, details) {
    super(message)
    this.name = this.constructor.name   // 'NotFoundError', 'ValidationError', etc.
    this.statusCode = statusCode
    this.code = code                    // machine-readable string for the client
    this.details = details              // optional: field-level validation issues, etc.
    // Restore the prototype chain (required when subclassing Error in TypeScript/older JS):
    Object.setPrototypeOf(this, new.target.prototype)
  }

  toJSON() {
    return { error: this.message, code: this.code, details: this.details }
  }
}
`Object.setPrototypeOf` fixes `instanceof` for Error subclasses in TypeScript
When you extend `Error` (or any built-in) in TypeScript or ES5-transpiled code, the prototype chain can be broken — `err instanceof NotFoundError` returns `false` even when it should be `true`. The fix is `Object.setPrototypeOf(this, new.target.prototype)` in the constructor, which restores the correct prototype. It's boilerplate, but putting it in the base class once means all subclasses get it for free.
Domain-specific subclasses

errors/index.js

JS
import { HttpError } from './HttpError.js'

export class NotFoundError extends HttpError {
  constructor(resource = 'Resource') {
    super(404, `${resource} not found`, 'NOT_FOUND')
  }
}

export class ValidationError extends HttpError {
  constructor(message, details) {
    super(400, message, 'VALIDATION_ERROR', details)
  }
}

export class UnauthorizedError extends HttpError {
  constructor(message = 'Not authenticated') {
    super(401, message, 'UNAUTHORIZED')
  }
}

export class ForbiddenError extends HttpError {
  constructor(message = 'Forbidden') {
    super(403, message, 'FORBIDDEN')
  }
}

export class ConflictError extends HttpError {
  constructor(message, code = 'CONFLICT') {
    super(409, message, code)
  }
}
Using the errors in handlers

JS
import { NotFoundError, ValidationError, ConflictError } from '../errors/index.js'

app.get('/users/:id', asyncHandler(async (req, res) => {
  const user = await db.users.findById(req.params.id)
  if (!user) throw new NotFoundError('User')         // 404
  res.json(user)
}))

app.post('/users', asyncHandler(async (req, res) => {
  const { error, value } = userSchema.validate(req.body)
  if (error) throw new ValidationError('Invalid input', error.details)  // 400

  try {
    const user = await db.users.create(value)
    res.status(201).json(user)
  } catch (err) {
    if (err.code === '23505') throw new ConflictError('Email already registered', 'DUPLICATE_EMAIL')
    throw err
  }
}))
Throw specific types at the source — the handler just reads the properties
When you throw `new NotFoundError('User')` the error carries `statusCode: 404`, `code: 'NOT_FOUND'`, and a readable message. The [centralised handler](/nodejs/centralized-error-handling) doesn't need to know about routes or resources — it reads `err.statusCode` and `err.toJSON()`. This separation means you can change how 404s are formatted in one place, and every route that throws `NotFoundError` automatically gets the update.
Catching specific error types

JS
import { ValidationError, NotFoundError } from '../errors/index.js'

// In a service layer, translate library errors to domain errors:
async function createUser(data) {
  try {
    return await db.users.create(data)
  } catch (err) {
    if (err.code === '23505') throw new ConflictError('Email already registered')
    throw err   // re-throw unexpected errors
  }
}

// In tests — assert on the error type, not the message string:
await expect(createUser({ email: 'taken@x.com' })).rejects.toBeInstanceOf(ConflictError)
TypeScript variant

TS
export class HttpError extends Error {
  readonly statusCode: number
  readonly code: string
  readonly details?: unknown

  constructor(statusCode: number, message: string, code: string, details?: unknown) {
    super(message)
    this.name = this.constructor.name
    this.statusCode = statusCode
    this.code = code
    this.details = details
    Object.setPrototypeOf(this, new.target.prototype)
  }
}

export class NotFoundError extends HttpError {
  constructor(resource = 'Resource') {
    super(404, `${resource} not found`, 'NOT_FOUND')
  }
}
What to put in `details`
  • Validation errors — array of { field, message } objects so the client can highlight which input failed.

  • Never — stack traces, SQL queries, internal IDs, or anything an attacker could use.

  • Keep details safe to serialize to JSON and send to the client — it goes in the response body.

  • Use details for user-facing context only; log the full error (with stack) separately server-side.

Next
One handler to catch them all: [Centralized Error Handling](/nodejs/centralized-error-handling).