The most important sentence in back end development is: never trust the client. Anyone can send anything to your API: missing fields, numbers where text is expected, a ten-megabyte string, JSON that is not JSON. If your code assumes good input, the first bad request either crashes the server or, worse, stores garbage. This lesson adds two protections to the jobs API: validation (check and clean the input) and error handling (turn every failure into a clear, safe answer).
Validation: check, clean, then use
import { HttpError } from '../utils/HttpError.js';
export const STATUSES = ['saved', 'applied', 'interview', 'offer', 'rejected'];
const text = (v) => (typeof v === 'string' ? v.trim() : '');
// Check and clean the body of a job. `partial` is true for updates, where every field is optional.
export function cleanJob(body = {}, { partial = false } = {}) {
const problems = {};
const out = {};
for (const field of ['company', 'role']) {
if (body[field] === undefined && partial) continue;
const v = text(body[field]);
if (!v) problems[field] = `${field} is required`;
else if (v.length > 120) problems[field] = `${field} must be 120 characters or fewer`;
else out[field] = v;
}
if (body.status !== undefined) {
if (!STATUSES.includes(body.status)) problems.status = `status must be one of: ${STATUSES.join(', ')}`;
else out.status = body.status;
} else if (!partial) out.status = 'saved';
if (Object.keys(problems).length) throw new HttpError(400, 'Please fix the highlighted fields', problems);
return out;
}
cleanJob receives the body and does three things. It checks that required fields exist and have a sensible length. It cleans the values with trim(). And it copies only the fields it knows, so that if a client adds { "admin": true }, that field is simply ignored. This last step prevents a class of bugs called *mass assignment*. Problems are collected in one object so the client can show all of them at once, not one per attempt.
The partial option exists for updates: when changing a job, every field is optional, but the ones you send must still be valid. That is why the function checks body[field] === undefined && partial first.
A custom error that carries a status
// An error that knows which HTTP status to answer with.
export class HttpError extends Error {
constructor(status, message, details) {
super(message);
this.status = status;
this.details = details;
}
}
A normal Error has a message but no idea about HTTP. HttpError adds a status and optional details. Now any code, a controller, a validator or a middleware, can throw new HttpError(404, 'Job not found') and know the right answer will be sent. Throwing is better than writing res.status(404).json(...) and return in every place, because it stops the function immediately and keeps the happy path clean.
One place that answers every error
// Runs when no route matched.
export const notFound = (req, res) => {
res.status(404).json({ message: `No route for ${req.method} ${req.originalUrl}` });
};
// Runs when anything throws (Express 5 also forwards errors from async functions to here).
// It has four parameters: that is how Express knows it is an error handler.
// eslint-disable-next-line no-unused-vars
export const errorHandler = (err, req, res, next) => {
if (err.type === 'entity.parse.failed') return res.status(400).json({ message: 'The request body is not valid JSON' });
const status = err.status || 500;
if (status >= 500) console.error(err); // log real bugs, but do not leak details to the client
res.status(status).json({
message: status >= 500 ? 'Something went wrong on the server' : err.message,
...(err.details ? { details: err.details } : {}),
});
};
An error-handling middleware has *four* parameters, (err, req, res, next). That is how Express recognises it, so keep all four even if next is unused. Express sends any thrown or passed error to it. It also receives errors from async functions in Express 5, so a rejected await ends up here without a try/catch.
- Known errors (those with a
statusbelow 500) are shown to the client with their message and details. - Unknown errors are bugs. They are logged on the server, but the client only gets a generic “Something went wrong”. Showing the real message could leak file paths or database details to an attacker.
notFoundhandles requests that matched no route and gives a JSON 404 instead of Express's default HTML page.
Using it in the controller
import { HttpError } from '../utils/HttpError.js';
import { cleanJob } from '../middleware/validate.js';
let nextId = 3;
const jobs = [
{ id: 1, company: 'Northwind Bank', role: 'Teller', status: 'applied' },
{ id: 2, company: 'Acme Logistics', role: 'Dispatcher', status: 'interview' },
];
const find = (id) => {
const job = jobs.find((j) => j.id === Number(id));
if (!job) throw new HttpError(404, 'Job not found');
return job;
};
export const listJobs = (req, res) => res.json(jobs);
export const getJob = (req, res) => res.json(find(req.params.id));
export const createJob = (req, res) => {
const job = { id: nextId++, ...cleanJob(req.body) };
jobs.push(job);
res.status(201).json(job);
};
export const updateJob = (req, res) => {
const job = find(req.params.id);
Object.assign(job, cleanJob(req.body, { partial: true }));
res.json(job);
};
export const deleteJob = (req, res) => {
const job = find(req.params.id);
jobs.splice(jobs.indexOf(job), 1);
res.status(204).end();
};
Compare this controller with lesson 11. The repeated if (!job) return res.status(404)... is now one small find function that throws. The create and update routes call cleanJob, so bad input never reaches the data. And index.js registers notFound after all the routes and errorHandler last of all, because Express treats the error middleware as the final stop.
// Lesson 12: validation and error handling.
import 'dotenv/config';
import express from 'express';
import cors from 'cors';
import { logger } from './middleware/logger.js';
import { notFound, errorHandler } from './middleware/error.js';
import jobsRouter from './routes/jobs.js';
const app = express();
const port = Number(process.env.PORT) || 5000;
app.use(logger);
app.use(cors({ origin: process.env.CLIENT_ORIGIN || 'http://localhost:5173' }));
app.use(express.json({ limit: '100kb' }));
app.get('/api/health', (req, res) => res.json({ ok: true }));
app.use('/api/jobs', jobsRouter);
app.use(notFound); // after all routes
app.use(errorHandler); // last of all
app.listen(port, () => console.log(`API listening on http://localhost:${port}`));
Your task
- Run the server. Send
POST /api/jobswith{ "company": "", "status": "maybe" }and read thedetailsobject in the answer. - Send invalid JSON (for example
{ "company":) and confirm you get400with a clear message, not an HTML page. - Call
GET /api/nothingandGET /api/jobs/999. Both should answer 404 in JSON. - Add a
locationfield tocleanJob(optional, at most 120 characters).
How to check it worked
- Bad input answers 400 and tells you which fields are wrong.
- Unknown routes and missing jobs answer 404 as JSON.
- A deliberate bug (throw a normal Error in a route) answers 500 with a generic message and logs the details in your terminal.
Summary
- Check, clean and whitelist input; return all field errors together.
- Throw
HttpErroranywhere; one error middleware (four parameters, registered last) sends the answer. - Log unknown errors on the server; never show their details to the client.