0 / 91
Week 6 · Day 40 of 91

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 and req.body is undefined. Your code must survive that.
  • If the header says JSON but the text is malformed, parsing throws. Handled badly, that becomes a 500 blamed 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.

  1. Before writing code, write the case table above into README.md in your own words. Add one case this lesson did not list.
  2. Add app.use(express.json()), the Status type, and validateNewEquipment to src/server.ts. Run npm run typecheck.
  3. Add the POST /equipment route. Confirm a valid request returns 201 with a Location header.
  4. Confirm GET /equipment now includes the created record.
  5. Run each failing case from your table and record the exact response. All must be 400.
  6. Send a duplicate serial and confirm the message names the value.
  7. Add the error handler last and confirm malformed JSON returns 400, not 500.
  8. 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.
  9. Update the endpoint table in README.md with POST /equipment and 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.body is undefined and every request looks invalid. The stack runs top to bottom.
  • Forgetting -H "Content-Type: application/json" in curl. curl defaults to application/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 200 with an error message inside. Callers check the status code first. A failure reported as 200 is invisible to every generic client.
  • Letting malformed JSON become a 500. The sender made the mistake, so it is a 4xx. 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.

  1. Confirm from the docs which Content-Type values express.json() handles by default. Does it match this lesson's claim?
  2. Find express.json()'s limit option and note its default. Why would an API want a body size limit at all?
  3. 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

  1. 0–5 min Recall

    Without notes, state yesterday’s main idea and one unresolved question.

  2. 5–20 min Learn

    Read only the listed concept notes and official reference sections needed today.

  3. 20–48 min Build

    Add POST /equipment with validation for name, serial number, and status.

  4. 48–55 min Explain and verify

    Run the result, inspect evidence, and explain the data/control flow in your own words.

  5. 55–60 min Quiz and commit

    Complete the quiz, record one lesson, and commit the verified change when applicable.

What to hand in

Deliverable

Valid requests create records; invalid requests return useful 400 responses.

Working with AI today

AI as pair programmer

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

Q1 Where should untrusted request data be validated?
Q2 Which result best proves today’s work is complete?
Q3 Before accepting an AI-generated code change, what should you do?

Explain-back gate

Pass the quiz above to unlock completion.

Quiz + explain-back checks required.