Never trust the client
Back in lesson 1-1's quiz you learned that frontend checks are decoration: anyone can send your API anything with curl, a command-line program (installed on most systems) that sends whatever HTTP request you type, like curl -X POST localhost:3000/signup -d '{...}'. No browser, no form, none of your frontend checks. So every input gets validated at the edge, the moment it enters your server, before any logic runs.
In Express, JSON bodies first need parsing:
app.use(express.json()); // middleware: parses JSON bodies into req.body
Then a validation step checks req.body against what the endpoint expects, and rejects with a 400 plus the error shape from lesson 4-3 when it fails. Real projects use libraries (zod, joi) for this, but the mechanism is a loop over expected fields, which you can write in ten lines.
A mini schema checker
Each schema key names a required field and the typeof it must have.
function check(body, schema) { const errors = []; for (const key of Object.keys(schema)) { if (!(key in body)) { errors.push(key + " is required"); } else if (typeof body[key] !== schema[key]) { errors.push(key + " must be a " + schema[key]); } } return errors; } const bookSchema = { title: "string", pages: "number" }; console.log(JSON.stringify(check({ title: "Dune", pages: 412 }, bookSchema))); console.log(JSON.stringify(check({ title: 5 }, bookSchema)));
Output
[] ["title must be a string","pages is required"]
key in body asks whether the property exists at all, and typeof checks its type, so the two branches distinguish a missing field from a wrong one. Those are different problems for the client, and the messages say which.
The else if matters, because a missing key cannot also have a wrong type. Running both checks would report undefined must be a string alongside the required message, which is noise.
The loop walks the schema rather than the body, which is the direction that catches missing fields. Iterating the body would only ever validate what was sent, and the whole point is to notice what was not.
Both problems in the second call are collected into one array, so the client learns everything at once. That is the same design decision as the signup validator in lesson 4-3.
Note what the checker does not do. Extra fields in the body pass silently, null counts as "object" in JavaScript, and there is no way to say optional or to constrain a number's range, which is the entire reason libraries like zod exist.
An unvalidated page number
With items.slice((req.query.page - 1) * 20) and a request of ?page=banana, "banana" - 1 is NaN, slice(NaN) behaves like slice(0), and subtle nonsense ships to the client.
Query values are raw strings and Express passes them through untouched. "banana" - 1 is NaN, and NaN flows silently through the arithmetic, so there is no crash and no error, just wrong behavior.
That silence is what makes it worse than a crash. A 500 gets noticed and fixed, and a page 1 served under the name of page banana looks fine in every dashboard while the client's paging controls quietly misbehave.
The same class of bug has nastier versions with larger inputs. ?page=1e9 produces a huge offset, and against a database that becomes a query the planner has to walk, so an unvalidated number is a small denial-of-service surface as well as a correctness problem.
Numeric inputs need explicit coercion with a safe fallback, and rejecting outright with a 400 is the other defensible choice. Which one to pick depends on whether a bad value is worth telling the client about, and either is better than letting NaN through.
A safe integer coercion
toPositiveInt(value, fallback) converts with Number(...) and returns it only if the result is an integer of at least 1.
function toPositiveInt(value, fallback) { const n = Number(value); if (!Number.isInteger(n) || n < 1) return fallback; return n; } console.log(toPositiveInt("3", 1)); console.log(toPositiveInt("0", 1)); console.log(toPositiveInt("banana", 1)); console.log(toPositiveInt("2.5", 1)); console.log(toPositiveInt(undefined, 1));
Output
3 1 1 1 1
Number("banana") is NaN and Number.isInteger(NaN) is false, so one check covers the garbage case without a separate isNaN test. The full condition is !Number.isInteger(n) || n < 1, and everything failing it returns the fallback.
Number(undefined) is also NaN, which is why a missing query parameter needs no special handling. That is the common case rather than the exotic one, since ?page= is absent on the first request to any list endpoint.
"2.5" is rejected because Number.isInteger(2.5) is false, which is the reason to use it rather than parseInt. parseInt("2.5") returns 2 and parseInt("2abc") returns 2 as well, so it accepts input it should refuse.
Rejecting "0" and negatives is what makes the name honest. Page 0 would compute a negative offset, and slice(-20) returns the last twenty items, which is a genuinely confusing response to a genuinely wrong request.
This exact helper guards the pagination endpoint from lesson 4-2, and the pattern generalizes to every numeric input. Convert once at the edge, validate immediately, and let the rest of the handler work with a value it can trust.