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
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 }
}
}Domain-specific subclasses
errors/index.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
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
}
}))Catching specific error types
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
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
detailssafe to serialize to JSON and send to the client — it goes in the response body.Use
detailsfor user-facing context only; log the full error (with stack) separately server-side.