Learn how to create custom error classes like NotFoundError in Express 5, throw them from any route, and turn them into consistent JSON error responses in one handler.

Bishal Biswas
Self Learner
What you will learn
- Why
const err = new Error(); err.status = 404gets messy fast - How to build an
HttpErrorclass and small subclasses likeNotFoundError - How to give every error response the same shape, with a machine-readable code
- The difference between expected and unexpected errors
- How the popular
http-errorspackage does the same job
The problem with plain errors
In the last lesson, a route signalled “not found” like this:
const err = new Error('User not found');
err.status = 404;
throw err;
Three lines for one idea. Now imagine dozens of routes doing this, each slightly differently. Someone types err.statusCode, someone else forgets the status entirely, and a “not found” turns into a 500.
What you want instead is one short, readable line:
throw new NotFoundError('User not found');
That is what custom error classes give you.
A quick refresher: classes and extends
A class is a blueprint for creating objects. JavaScript’s built-in Error is a class. With extends you can make your own class that inherits everything from Error (the message, the stack trace) and adds new properties.
Think of it like vehicle types. Every car is a vehicle, but a fire truck is a special vehicle with a ladder and a siren. NotFoundError is a special Error with a status code built in.
// a tiny example
class NotFoundError extends Error {
constructor(message) {
super(message); // let Error set up message and stack
this.name = 'NotFoundError';
this.status = 404;
}
}
const err = new NotFoundError('User not found');
console.log(err.message); // User not found
console.log(err.status); // 404
console.log(err instanceof Error); // true
console.log(err instanceof NotFoundError); // true
super(message) calls the parent Error constructor. You must call it before using this. The instanceof operator checks whether an object was made from a class, or from one of its children. Your error handler will rely on it.
Building an error toolkit
Let’s put all our error classes in one file. First, a base class that every HTTP error shares. Then small subclasses for the common status codes.
// errors.js
export class HttpError extends Error {
constructor(status, message, { code, details, cause } = {}) {
super(message, { cause }); // "cause" keeps the original error, if any
this.name = this.constructor.name; // e.g. "NotFoundError"
this.status = status;
this.code = code ?? 'HTTP_ERROR';
this.details = details;
}
}
export class BadRequestError extends HttpError {
constructor(message = 'Bad request', options = {}) {
super(400, message, { code: 'BAD_REQUEST', ...options });
}
}
export class UnauthorizedError extends HttpError {
constructor(message = 'Please log in', options = {}) {
super(401, message, { code: 'UNAUTHORIZED', ...options });
}
}
export class ForbiddenError extends HttpError {
constructor(message = 'You do not have access', options = {}) {
super(403, message, { code: 'FORBIDDEN', ...options });
}
}
export class NotFoundError extends HttpError {
constructor(message = 'Not found', options = {}) {
super(404, message, { code: 'NOT_FOUND', ...options });
}
}
export class ConflictError extends HttpError {
constructor(message = 'Conflict', options = {}) {
super(409, message, { code: 'CONFLICT', ...options });
}
}
A few things to notice:
this.constructor.namegives the class name automatically, so each subclass gets the rightnamewithout repeating it.codeis a short, fixed, machine-readable label likeNOT_FOUND. Messages may change or be translated, but codes stay stable, so frontend apps can safely check them.detailsholds extra information, such as which form fields were invalid.causeis a standardErroroption that stores the original, lower-level error. It is handy for logs.
Using the errors in routes
Now routes become short and readable. Every failure is one throw:
// routes/users.js
import { Router } from 'express';
import { BadRequestError, ConflictError, NotFoundError } from '../errors.js';
const router = Router();
const users = [{ id: 1, email: 'asha@example.com' }];
router.get('/:id', async (req, res) => {
const user = users.find((u) => u.id === Number(req.params.id));
if (!user) {
throw new NotFoundError(`User ${req.params.id} not found`, { code: 'USER_NOT_FOUND' });
}
res.json(user);
});
router.post('/', async (req, res) => {
const email = req.body?.email;
if (!email) {
throw new BadRequestError('Invalid user data', {
details: [{ field: 'email', message: 'email is required' }],
});
}
if (users.some((u) => u.email === email)) {
throw new ConflictError('Email already registered', { code: 'EMAIL_TAKEN' });
}
const user = { id: users.length + 1, email };
users.push(user);
res.status(201).json(user);
});
export default router;
Because these handlers are async, Express 5 forwards every thrown error to your error handler automatically. No try/catch, no next(err).
Tip: You can throw from a normal (non-async) handler too. Synchronous throws have always been caught by Express.
One handler that understands them all
The error handler now checks: “Is this one of my HTTP errors?” If yes, it can trust the status and message. If not, it is an unexpected bug, so it responds with a generic 500.
// app.js
import express from 'express';
import usersRouter from './routes/users.js';
import { HttpError } from './errors.js';
const app = express();
app.use(express.json());
app.use('/users', usersRouter);
app.use((err, req, res, next) => {
if (res.headersSent) return next(err);
if (err instanceof HttpError) {
// expected error: safe to show
return res.status(err.status).json({
error: { code: err.code, message: err.message, details: err.details },
});
}
// unexpected error: log everything, show nothing sensitive
console.error(err);
res.status(500).json({
error: { code: 'INTERNAL_ERROR', message: 'Internal Server Error' },
});
});
app.listen(3000, (error) => {
if (error) throw error;
console.log('Server running on http://localhost:3000');
});
Try it:
curl http://localhost:3000/users/99
curl -X POST http://localhost:3000/users -H "Content-Type: application/json" -d '{}'
curl -X POST http://localhost:3000/users -H "Content-Type: application/json" -d '{"email":"asha@example.com"}'
{"error":{"code":"USER_NOT_FOUND","message":"User 99 not found"}}
{"error":{"code":"BAD_REQUEST","message":"Invalid user data","details":[{"field":"email","message":"email is required"}]}}
{"error":{"code":"EMAIL_TAKEN","message":"Email already registered"}}
Every error now has the same shape. Frontend developers can write one piece of code to display any error from your API. (details disappears when it is undefined, because JSON.stringify skips undefined values.)
Expected vs unexpected errors
This split is the heart of good error handling, so let’s name it clearly.
| Expected errors | Unexpected errors | |
|---|---|---|
| Also called | Operational errors | Programmer errors, bugs |
| Examples | User not found, invalid input, wrong password, email taken | undefined is not a function, database down, typo in code |
| Status | 4xx (sometimes 503) | 500 |
| Show the message? | Yes, it is written for the user | No, log it and show a generic message |
| How to signal | throw new NotFoundError(...) |
They just happen |
Your custom classes are for expected errors. Anything that is not an HttpError is treated as unexpected. That keeps you safe by default: if you forget to wrap something, it never leaks internal details.
Translating library errors
Libraries throw their own errors. A database might throw “duplicate key”, or a JSON parser might throw a SyntaxError. Your users should not see those raw messages. Translate them into your own classes, keeping the original as cause:
// routes/users.js
router.post('/import', async (req, res) => {
try {
await db.insertUser(req.body);
} catch (err) {
if (err.code === 'DUPLICATE_KEY') {
throw new ConflictError('Email already registered', { code: 'EMAIL_TAKEN', cause: err });
}
throw err; // anything else stays unexpected
}
res.status(201).end();
});
The cause does not go to the client, but it shows up when you log the error, so you can still see what really happened. You will use this pattern with Mongoose’s CastError in CRUD API with MongoDB and Mongoose.
You can also translate in the error handler itself, which is handy for errors that come from middleware you did not write. For example, express.json() errors already carry status: 400 and a type:
// app.js (inside the error handler, before the instanceof check)
if (err.type === 'entity.parse.failed') {
err = new BadRequestError('Request body is not valid JSON', { cause: err });
}
The http-errors package
You do not have to write your own classes. The http-errors package, maintained by the same community as Express, creates HTTP errors for every status code.
npm install http-errors
// routes/users.js (with http-errors)
import createError from 'http-errors';
router.get('/:id', async (req, res) => {
const user = users.find((u) => u.id === Number(req.params.id));
if (!user) {
throw createError(404, 'User not found');
}
res.json(user);
});
Errors from http-errors have a status, a message, and an expose property. expose is true for 4xx errors (safe to show the message) and false for 5xx errors. Your handler can use it like this:
// app.js (error handler using http-errors)
import createError from 'http-errors';
app.use((err, req, res, next) => {
if (res.headersSent) return next(err);
if (createError.isHttpError(err) && err.expose) {
return res.status(err.status).json({ error: err.message });
}
console.error(err);
res.status(500).json({ error: 'Internal Server Error' });
});
Which should you choose?
| Your own classes | http-errors |
|---|---|
Full control over fields like code and details |
Ready to use, covers every status code |
| A few lines of code you own | One more dependency |
| Great for APIs with a fixed error format | Great for quick projects |
Both are fine. This course uses its own small errors.js file, because it makes the idea visible.
Note: There is also an official standard for error bodies called Problem Details (RFC 9457), which uses the
application/problem+jsoncontent type and fields liketype,title,statusanddetail. Some teams adopt it so different APIs share one error format. The class-based approach in this lesson works with it too.
Common mistakes
Common mistake: Forgetting
super(message)in a subclass constructor. JavaScript throwsReferenceError: Must call super constructor in derived class before accessing 'this' or returning from derived constructoras soon as you create the error.
Common mistake: Throwing strings:
throw 'Not found'. A string has no stack trace and nostatus, andinstanceofchecks fail. Always throwErrorobjects or your own classes.
Common mistake: Putting secrets in error messages, like
throw new HttpError(500, 'DB password rejected for admin@...'). Treat any message you might show as public.
Key takeaways
- Custom error classes turn “create error, set status, throw” into one readable line.
- Use a base
HttpErrorwithstatus,codeanddetails, plus small subclasses likeNotFoundError. - In the error handler,
instanceof HttpErrorseparates expected errors (show them) from unexpected ones (log and hide). - Translate library errors into your own classes and keep the original as
cause. - The
http-errorspackage is a ready-made alternative withcreateError(404)anderr.expose.
Practice
- Create
errors.jswithHttpError,NotFoundErrorandBadRequestError, and update one of your apps to use them. - Add a
TooManyRequestsError(status 429) with the codeRATE_LIMITED. Throw it from a test route and check the response. - Change your error handler so unexpected errors also include a random
requestIdin the response and the log, so users can report it and you can find it. - Rewrite one route with
http-errorsinstead of your own classes and compare the two styles.
What’s next
There is one kind of error that no route ever throws: a request that matches no route at all. Continue with Not Found and Fallback Handling.
Was this explanation helpful?
Your feedback helps us improve the content for everyone.
Thank you for your feedback!
We appreciate you helping us improve.
What could we improve? (Optional)
Read aloud
Ready