Difficulty: Intermediate
While JavaScript's built-in Error class works for simple cases, real applications need more structured errors. Custom error classes let you attach metadata like HTTP status codes, error codes, and whether the error is operational (expected, like 'user not found') or a programming bug (unexpected, like null reference). This distinction is crucial for deciding how to handle errors - operational errors get user-friendly messages, programming errors trigger alerts and need fixing.
Creating custom error classes in JavaScript uses the class syntax with extends Error. The key points are: call super(message) to set the error message, set this.name to the class name for proper error identification, and add any custom properties you need. The most common pattern is an AppError base class with a statusCode property, and specialized subclasses like NotFoundError, ValidationError, and UnauthorizedError.
Operational errors are anticipated failures that occur during normal operation - a user submitting invalid data, a requested resource not existing, a network timeout, or a database constraint violation. These are not bugs; they are expected edge cases your code should handle gracefully. Programming errors are actual bugs - accessing undefined properties, passing wrong types, or logic errors. The distinction matters because operational errors should be communicated to the user, while programming errors should be logged and investigated.
Using instanceof checks in your error handler lets you respond differently to each error type. A ValidationError returns 400 with field-specific details. A NotFoundError returns 404 with the resource name. An UnauthorizedError returns 401. Any error that is not an instance of your custom classes is treated as an unexpected programming error and returns a generic 500 response.
This pattern scales well as your application grows. New team members can quickly understand the error types by looking at the class hierarchy. Adding a new error type is as simple as creating a new class. The centralized error handler stays clean because each error class carries its own status code and formatting logic.
class AppError extends Error {
constructor(message, statusCode, isOperational = true) {
super(message);
this.name = this.constructor.name;
this.statusCode = statusCode;
this.isOperational = isOperational;
Error.captureStackTrace(this, this.constructor);
}
}
class NotFoundError extends AppError {
constructor(resource = 'Resource') {
super(`${resource} not found`, 404);
}
}
class ValidationError extends AppError {
constructor(message, fields = []) {
super(message, 400);
this.fields = fields;
}
}
class UnauthorizedError extends AppError {
constructor(message = 'Authentication required') {
super(message, 401);
}
}
class ForbiddenError extends AppError {
constructor(message = 'Insufficient permissions') {
super(message, 403);
}
}
// Usage
try {
throw new NotFoundError('User');
} catch (err) {
console.log('Name:', err.name);
console.log('Message:', err.message);
console.log('Status:', err.statusCode);
console.log('Is operational:', err.isOperational);
console.log('Is AppError:', err instanceof AppError);
console.log('Is NotFoundError:', err instanceof NotFoundError);
}
AppError is the base class with statusCode and isOperational flag. Subclasses like NotFoundError set sensible defaults. instanceof works through the inheritance chain - a NotFoundError is also an AppError, enabling catch blocks to handle errors at different granularity levels.
class AppError extends Error {
constructor(message, statusCode, isOperational = true) {
super(message);
this.name = this.constructor.name;
this.statusCode = statusCode;
this.isOperational = isOperational;
}
}
class ValidationError extends AppError {
constructor(message, fields = []) {
super(message, 400);
this.fields = fields;
}
}
function errorHandler(err, req, res, next) {
// Log all errors
console.error(`[${err.name}] ${err.message}`);
// Handle known operational errors
if (err instanceof ValidationError) {
return res.status(400).json({
error: err.message,
fields: err.fields
});
}
if (err instanceof AppError && err.isOperational) {
return res.status(err.statusCode).json({
error: err.message
});
}
// Handle Prisma known errors
if (err.code === 'P2002') {
return res.status(409).json({
error: 'A record with this value already exists'
});
}
// Unknown/programming errors
console.error('UNEXPECTED ERROR:', err.stack);
res.status(500).json({ error: 'Something went wrong' });
}
console.log('Error handler with custom class support defined');
console.log('Handles: ValidationError, AppError, Prisma errors, unknown errors');
The error handler uses instanceof to check the error type and respond accordingly. ValidationErrors include field details. Operational AppErrors use their statusCode. Prisma errors (like unique constraint P2002) get translated. Unknown errors are logged fully but send a generic message to the client.
const express = require('express');
const app = express();
app.use(express.json());
class AppError extends Error {
constructor(message, statusCode) {
super(message);
this.statusCode = statusCode;
}
}
class NotFoundError extends AppError {
constructor(resource) { super(`${resource} not found`, 404); }
}
class ValidationError extends AppError {
constructor(message) { super(message, 400); }
}
function asyncHandler(fn) {
return (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
}
const users = [{ id: 1, name: 'Alice', email: 'alice@test.com' }];
app.get('/api/users/:id', asyncHandler(async (req, res) => {
const id = parseInt(req.params.id);
if (isNaN(id)) throw new ValidationError('ID must be a number');
const user = users.find(u => u.id === id);
if (!user) throw new NotFoundError('User');
res.json(user);
}));
app.post('/api/users', asyncHandler(async (req, res) => {
const { name, email } = req.body;
if (!name || !email) throw new ValidationError('Name and email are required');
if (users.find(u => u.email === email)) throw new AppError('Email already taken', 409);
const user = { id: users.length + 1, name, email };
users.push(user);
res.status(201).json(user);
}));
console.log('Routes using custom error classes configured');
Route handlers throw specific error types instead of manually setting status codes. This makes the code declarative and self-documenting - 'throw new NotFoundError("User")' clearly communicates what happened. The asyncHandler catches these and forwards them to the error middleware.
AppError, HttpError, operational errors, programming errors, error hierarchy, instanceof