A server receives data from strangers, so everything in req.body, req.params and req.query must be treated as untrusted until you have checked it. Validation is your first line of defence against broken data and many attacks. Error handling is the second: it makes sure that when something fails, the client receives a clear, safe message and not a crash or a page of internal details. In this lesson you will add both to the task API.
Write a validation function
You can use a library such as Zod or Joi, and many teams do. For a small API a short function is enough, and it shows exactly what validation means. This function checks a task. It returns either a clean value or an error message. The partial option is for PATCH, where fields are optional.
// Returns { value } for good input or { error } with a message for the client.
export function checkTask(body, { partial = false } = {}) {
if (typeof body !== 'object' || body === null) return { error: 'Send a JSON object' };
const out = {};
if ('title' in body || !partial) {
if (typeof body.title !== 'string' || body.title.trim() === '') return { error: 'title is required' };
if (body.title.length > 120) return { error: 'title must be 120 characters or fewer' };
out.title = body.title.trim();
}
if ('done' in body) {
if (typeof body.done !== 'boolean') return { error: 'done must be true or false' };
out.done = body.done;
}
return { value: out };
}
- It checks the type of every field. A title must be a text, not a number or an array.
- It trims spaces and sets a maximum length.
- It builds a new object with only the allowed fields. Extra fields sent by the client, such as
idorownerId, are ignored. This stops a client from changing fields they should not control. - It returns the first problem as a plain message that the client can show to a user.
Use it in the routes
In the create and update routes, call it first, and answer 400 if there is an error. 400 Bad Request means the client sent something wrong. Compare this with 500, which means the server itself failed. Never use 500 for bad input.
import express from 'express';
import { checkTask } from './validate.js';
const app = express();
app.use(express.json());
let tasks = [{ id: 1, title: 'Learn Node', done: true }];
let nextId = 2;
app.get('/api/tasks', (req, res) => res.json(tasks));
app.get('/api/tasks/:id', (req, res) => {
const task = tasks.find((t) => t.id === Number(req.params.id));
if (!task) return res.status(404).json({ error: 'Task not found' });
res.json(task);
});
app.post('/api/tasks', (req, res) => {
const { value, error } = checkTask(req.body);
if (error) return res.status(400).json({ error });
const task = { id: nextId++, title: value.title, done: false };
tasks.push(task);
res.status(201).json(task);
});
app.get('/api/crash', async () => {
throw new Error('Something broke on purpose'); // Express 5 catches this
});
// 1. Anything that did not match a route above
app.use((req, res) => {
res.status(404).json({ error: 'Not found' });
});
// 2. The error handler: it has FOUR parameters
app.use((err, req, res, next) => {
if (err.type === 'entity.parse.failed') return res.status(400).json({ error: 'Body is not valid JSON' });
if (err.type === 'entity.too.large') return res.status(413).json({ error: 'Body is too large' });
console.error(err);
res.status(500).json({ error: 'Something went wrong' });
});
app.listen(3000, () => console.log('Server running at http://localhost:3000'));
404 for unknown routes
When no route matches a request, Express sends a plain HTML message by default. For an API you want JSON. Add a middleware after all routes. Because it comes last, it only runs when nothing else answered.
One central error handler
The error handler is a middleware with four parameters, registered last. Express sends to it any error that is thrown in a handler, passed to next(err), or caused by a rejected promise in an async handler (this works automatically in Express 5). It is the one place where you decide how errors look to the client.
- Errors you can predict, such as invalid JSON or a body that is too big, get a clear 400 or 413 message. Those errors come from
express.json()and have anerr.type. - Everything else is a surprise. Log the full error on the server with
console.error, and send the client only a generalSomething went wrongwith status 500. - Never send
err.stackorerr.messageof unexpected errors to the client. They can reveal file paths, library versions and database details that help an attacker.
The sample above includes a route GET /api/crash that throws on purpose. Test the handler.
curl -X POST http://localhost:3000/api/tasks -H "Content-Type: application/json" -d '{}'
curl -X POST http://localhost:3000/api/tasks -H "Content-Type: application/json" -d '{bad'
curl http://localhost:3000/api/crash
curl http://localhost:3000/nothing
{"error":"title is required"}
{"error":"Body is not valid JSON"}
{"error":"Something went wrong"}
{"error":"Not found"}
Each failure now returns clean JSON with the right status code. In the server terminal you will see the full stack trace for the crash, which is what you want for debugging. The client never sees it. Remove the /api/crash route after testing, because it is only for practice.
Express 4 note
If you ever work on an Express 4 project, async handlers need extra care. Wrap them, or call next(err) inside catch, because errors in promises are not caught by Express 4. That is the main reason many older tutorials contain wrappers such as asyncHandler. In Express 5 you do not need them.
Use consistent error responses
- Always the same shape, for example
{ "error": "message" }, so the front end can handle all errors in one place. - Use codes that people can act on: 400 invalid data, 401 not logged in, 403 not allowed, 404 not found, 409 conflict (for example, an email already used), 413 too large, 429 too many requests, 500 server error.
- Write messages for the person who will read them. They should say what to fix. They should not reveal how the server works.
Your task
- Add
validate.jsand callcheckTaskin the create route. Test an empty body, a number as title and a 200-character title. - Add the 404 middleware and the error handler at the end of the file. Test the four curl commands above.
- Change the validation to also accept an optional
prioritythat must below,mediumorhigh. - Make
GET /api/tasks/:idreturn 400 when the id is not a whole number, and 404 when it is a number that does not exist.
How to check it worked
- An empty or wrong-typed title returns 400 with a clear message.
- Invalid JSON returns 400 JSON, not an HTML page.
- The crash route returns
Something went wrong, and the stack trace is only in the server terminal.