Without notes, state yesterday’s main idea and one unresolved question.
Request validation and error handling
HTTP, Node.js, and APIs
Objective
Reject bad data at the boundary and return consistent errors.
HTTP is a request/response protocol like a defined communication bus: method, address, headers, body, and status all have roles.
- body parsing
- schema checks
- central error handling
Why this matters
Every route so far only reads. Today your API accepts data from outside — a POST with a JSON body
— and that changes the threat model completely. The sender is not your code. They may send the
wrong types, missing fields, extra fields, or text that is not JSON at all, by mistake or on
purpose.
By the end of the hour, valid requests create a record and return 201 Created, and invalid ones
return a 400 that says exactly which field was wrong and why. That second half is the real work.
An API that only handles the happy path is an API nobody can integrate with, because when it fails
it tells you nothing.
Parsing the body
A request body arrives as raw bytes. On Day 38 you saw that collecting them is fiddly; Express does it with one line of middleware:
app.use(express.json());
Registered before your routes, express.json() reads the body of every incoming request, and — if
the request declares Content-Type: application/json — parses it and puts the result on req.body.
Two consequences worth internalising now:
- If the header does not say
application/json,express.json()leaves the body alone andreq.bodyisundefined. Your code must survive that. - If the header says JSON but the text is malformed, parsing throws. Handled badly, that becomes a
500blamed on your server for someone else's typo.
Validation is the protection network on the input
No sensible design wires a connector straight into the logic. You put a series resistor, a clamp diode, and a fuse at the boundary, because whatever arrives on that pin is outside your control and you would rather fail at the connector than downstream. Request validation is that protection network: it sits at the edge, it assumes the worst about the incoming signal, and it rejects out-of-range input before it can reach anything that acts on it. Once past validation, the rest of your code is allowed to trust its inputs — which is the entire benefit.
Say the rule plainly, because it decides where every check you write this week belongs: untrusted request data is validated at the application boundary, before any business logic runs. Not halfway down, not "wherever it breaks first". The route handler checks the request the moment it arrives; everything it calls afterwards may assume the data is already sound.
Goods inwards
A workshop checks a delivery at the loading bay: right parts, right count, undamaged. Anything wrong is refused at the door with a note saying what was wrong, and the delivery driver can fix it and come back. Accept the pallet first and discover the problem halfway through a build, and now you are unpicking work instead of refusing a delivery.
List the cases before you write the check
Do this in writing, before code. For POST /equipment the record needs name, serial, and
status, so the cases are:
| Case | Response |
|---|---|
| Body is missing or not a JSON object | 400, "body must be a JSON object" |
name missing, not a string, or empty |
400, naming name |
serial missing or wrong format |
400, naming serial |
serial already exists |
400, naming the duplicate |
status not one of the three allowed values |
400, listing the allowed values |
| Everything valid | 201, the created record with its new id |
| Body is not valid JSON at all | 400, "not valid JSON" |
Notice id is not in the list. The client does not choose the id — the server does. Anything
else lets a caller overwrite an existing record or invent unusable identifiers.
Notice also that a validation failure reports all the bad fields, not just the first. A caller fixing a form wants the whole list in one round trip.
Writing the check
TypeScript's types vanish at runtime (Day 35: the compiler cannot check data that arrives while the
program is running). So the body starts life as unknown and you narrow it yourself, exactly as on
Day 32.
type Status = "operational" | "maintenance" | "down";
const STATUSES: Status[] = ["operational", "maintenance", "down"];
type FieldError = { field: string; message: string };
function validateNewEquipment(body: unknown): FieldError[] {
const errors: FieldError[] = [];
if (typeof body !== "object" || body === null || Array.isArray(body)) {
return [{ field: "body", message: "body must be a JSON object" }];
}
const input = body as Record<string, unknown>;
if (typeof input.name !== "string" || input.name.trim() === "") {
errors.push({ field: "name", message: "name is required and must be a non-empty string" });
}
if (typeof input.serial !== "string" || !/^[A-Z0-9-]{4,}$/.test(input.serial)) {
errors.push({ field: "serial", message: "serial must be at least 4 characters of A-Z, 0-9, or -" });
} else if (equipment.some((item) => item.serial === input.serial)) {
errors.push({ field: "serial", message: `serial "${input.serial}" already exists` });
}
if (typeof input.status !== "string" || !STATUSES.includes(input.status as Status)) {
errors.push({ field: "status", message: `status must be one of: ${STATUSES.join(", ")}` });
}
return errors;
}
The function returns a list rather than throwing, so the route decides what to do with it. It is a pure function of its input: given a body, it always produces the same errors, which makes it trivially testable later.
Responding: 201, Location, and 400
app.post("/equipment", (req, res) => {
const errors = validateNewEquipment(req.body);
if (errors.length > 0) {
res.status(400).json({ error: "Validation failed", details: errors });
return;
}
const input = req.body as { name: string; serial: string; status: Status };
const created: Equipment = {
id: `eq-${String(nextId++).padStart(3, "0")}`,
name: input.name.trim(),
serial: input.serial,
status: input.status
};
equipment.push(created);
res.status(201).location(`/equipment/${created.id}`).json(created);
});
201 Created is the correct success status for a POST that made something new — more precise than
200, and it tells the caller a record now exists. res.location(path) sets the Location header
naming where the new record lives, which saves the caller guessing. Returning the created object
lets them see the id the server assigned.
Errors all share one shape — { error, details } — so a caller can write one piece of code to
handle every failure from your API. Consistency here is worth more than cleverness.
Central error handling
Some failures happen before your handler runs, or throw inside it. Express has one place for those: a middleware with four arguments. The fourth argument is what marks it as an error handler, and it must be registered last.
app.use((err: unknown, req: Request, res: Response, next: NextFunction) => {
if (err instanceof SyntaxError && "body" in err) {
res.status(400).json({ error: "Request body is not valid JSON" });
return;
}
console.error(err);
res.status(500).json({ error: "Internal server error" });
});
When express.json() fails to parse, it passes a SyntaxError carrying a body property down the
chain, and this handler turns it into a 400 — the sender's mistake, correctly attributed.
Everything else is logged in full on the server and reported to the client as a bare 500. Log
detail inward, reveal nothing outward: internal messages can leak file paths and structure.
Walkthrough
Add app.use(express.json()) near the top of src/server.ts, the validator and POST route among
your Day 39 routes, and the error handler after the 404 catch-all. Then, with npm run dev
running:
curl -i -s -X POST localhost:3000/equipment \
-H "Content-Type: application/json" \
-d '{"name":"Cooling Fan A","serial":"CFA-2021-008","status":"down"}'
HTTP/1.1 201 Created
Location: /equipment/eq-002
Content-Type: application/json; charset=utf-8
{"id":"eq-002","name":"Cooling Fan A","serial":"CFA-2021-008","status":"down"}
Now three bad requests:
curl -s -X POST localhost:3000/equipment -H "Content-Type: application/json" \
-d '{"name":"","serial":"ab","status":"broken"}'
{"error":"Validation failed","details":[{"field":"name","message":"name is required and must be a non-empty string"},{"field":"serial","message":"serial must be at least 4 characters of A-Z, 0-9, or -"},{"field":"status","message":"status must be one of: operational, maintenance, down"}]}
Three problems, one response. Next, malformed JSON:
curl -s -X POST localhost:3000/equipment -H "Content-Type: application/json" -d '{oops'
{"error":"Request body is not valid JSON"}
And a body sent with no Content-Type header at all:
curl -s -X POST localhost:3000/equipment -d '{"name":"X","serial":"XXXX","status":"down"}'
{"error":"Validation failed","details":[{"field":"body","message":"body must be a JSON object"}]}
express.json() ignored the body because the header did not claim JSON, so req.body was
undefined — and the first check caught it instead of crashing. That is the guard rail earning its
place.
Checkpoint
Say why express.json() must be registered before the routes, why the error handler must be
last, and why the validator returns an array instead of throwing.
Your turn
Deliverable: valid requests create records; invalid requests return useful 400 responses.
- Before writing code, write the case table above into
README.mdin your own words. Add one case this lesson did not list. - Add
app.use(express.json()), theStatustype, andvalidateNewEquipmenttosrc/server.ts. Runnpm run typecheck. - Add the
POST /equipmentroute. Confirm a valid request returns201with aLocationheader. - Confirm
GET /equipmentnow includes the created record. - Run each failing case from your table and record the exact response. All must be
400. - Send a duplicate
serialand confirm the message names the value. - Add the error handler last and confirm malformed JSON returns
400, not500. - Break something deliberately: throw an error inside a route (
throw new Error("boom")), call it, and confirm the client gets{"error":"Internal server error"}while the full stack appears in the server terminal. Remove it afterwards. - Update the endpoint table in
README.mdwithPOST /equipmentand all of its status codes.
Pair mode — at step 1, before any code
"Help me define validation cases first. Do not write the route until the cases are listed." Pair mode means you keep the decisions. Get the case list, argue with it, and only then ask for code. Before accepting anything generated, inspect the diff, run the failing requests yourself, and be able to explain every line — including which case each check corresponds to.
Common pitfalls
- No
express.json(), or registered after the routes.req.bodyisundefinedand every request looks invalid. The stack runs top to bottom. - Forgetting
-H "Content-Type: application/json"in curl. curl defaults toapplication/x-www-form-urlencoded, so the JSON parser skips the body. A real client will make this mistake too, which is why the "not an object" case exists. - Returning
200with an error message inside. Callers check the status code first. A failure reported as200is invisible to every generic client. - Letting malformed JSON become a
500. The sender made the mistake, so it is a4xx. Handling it deliberately is what the central error handler is for. - Trusting a client-supplied
id. Generate ids on the server, always.
Verify it yourself
Open today's reference, Express's Getting started pages, and find the section on express.json()
and the built-in middleware.
- Confirm from the docs which
Content-Typevaluesexpress.json()handles by default. Does it match this lesson's claim? - Find
express.json()'slimitoption and note its default. Why would an API want a body size limit at all? - Find Express's documentation of error-handling middleware. Does it agree the four-argument form is what makes a function an error handler, and that it goes last?
Add the answers to README.md. Tomorrow you turn this collection of endpoints into a deliberately
designed API — consistent names, stable ids, filtering, and pagination.
The hour
- 0–5 min Recall
- 5–20 min Learn
Read only the listed concept notes and official reference sections needed today.
- 20–48 min Build
Add POST /equipment with validation for name, serial number, and status.
- 48–55 min Explain and verify
Run the result, inspect evidence, and explain the data/control flow in your own words.
- 55–60 min Quiz and commit
Complete the quiz, record one lesson, and commit the verified change when applicable.
What to hand in
Valid requests create records; invalid requests return useful 400 responses.
Working with AI today
Define one small task, review the plan, inspect the diff, run checks, and explain every changed section.
Help me define validation cases first. Do not write the route until the cases are listed.
References
End-of-day quiz
Explain-back gate
Pass the quiz above to unlock completion.
Quiz + explain-back checks required.