Validating Input
Check the shape and range of every incoming field at the edge of the API, and return one error per field that failed.
const errors = validate(body); if (errors.length) sendJson(res, 422, { error: 'validation_failed', details: errors });JSON.parse tells you the text was valid JSON. It says nothing about whether the object has the fields your handler reads. Validation is that second check, done at the boundary, before any database call.
One rule per field
Write a small function per field that returns a message when the value is wrong and null when it is fine. Run every rule and collect every failure, rather than returning on the first one. A client fixing a form wants the whole list.
Return 422 with a details array of { field, message } objects, so the caller can put each message next to the input that caused it. Keep the field order fixed, so two identical bodies give identical responses.
Checks that actually hold
Use typeof v === 'string', Number.isInteger(v), Array.isArray(v) and explicit ranges. Query string values are always strings: Number('abc') is NaN and Number('') is 0, so check the result with Number.isInteger instead of trusting the conversion.
Reading a field has a short and a long form, and they mean the same thing:
const age = input && input.age !== undefined ? input.age : null;
const age = input?.age ?? null;
Decide what unknown fields mean. Ignoring them is forgiving; rejecting them catches a client sending e_mail and wondering why nothing saved.
Example
When to use it
- Reject a signup body missing an email with a 422 naming that field.
- Clamp ?limit= so no client can request the entire table.
- Keep a caller from setting a role column by spreading the request body into an update.
- Return every field error at once so a form can show them all.
More examples
Query parameters: convert, clamp, default
Query strings are the input people forget to validate, because they look harmless. Two things make this safe: clamping the limit means no client can ask for the whole table, and the allow-list on `sort` means the value reaching an ORDER BY clause can only ever be one of two known strings. Note that `Number('')` is 0, so an empty `?limit=` would silently become 0 without the integer check.
function readListQuery(searchParams) {
const errors = [];
// Every query value is a string, and a missing one is null.
const rawLimit = searchParams.get('limit');
let limit = 20;
if (rawLimit !== null) {
const n = Number(rawLimit);
if (!Number.isInteger(n) || n < 1) errors.push({ field: 'limit', message: 'must be a whole number of at least 1' });
// Clamp rather than reject: a client asking for 10000 rows gets 100.
else limit = Math.min(n, 100);
}
const sort = searchParams.get('sort') ?? 'created_at';
// An allow-list, never the raw string: this value ends up in an ORDER BY.
if (!['created_at', 'name'].includes(sort)) {
errors.push({ field: 'sort', message: 'must be created_at or name' });
}
return { value: { limit, sort }, errors };
}
readListQuery(new URL(req.url, 'http://localhost').searchParams);Rejecting unknown fields
Two defences that pair up. Reporting unknown fields turns a silent no-op into a 422 the client can act on, which catches typos like `e_mail`. Copying only allowed keys is the one that matters for safety: spreading a whole request body into an update lets a caller set columns such as `role` or `is_admin` that no form of yours exposes. `Object.hasOwn` keeps inherited keys out.
const ALLOWED = ['email', 'age', 'role'];
function unknownFields(input) {
return Object.keys(input)
.filter((key) => !ALLOWED.includes(key))
.map((key) => ({ field: key, message: 'unknown field' }));
}
// In an update handler, only copy fields you accept.
function pickUpdate(input) {
const update = {};
for (const key of ALLOWED) {
if (Object.hasOwn(input, key)) update[key] = input[key];
}
return update; // never spread the whole body into a database write
}The same validation with a schema library
A schema library replaces the rule functions with one declaration, and gives you the parsed value typed and trimmed of unknown keys. The shape of the response is unchanged: you still map its issues onto your own `{ field, message }` list rather than sending the library's error object to clients, so swapping libraries later does not break every API consumer. Reach for one as soon as nested objects and arrays appear in your bodies.
// After: npm install zod
const { z } = require('zod');
const CreateUser = z.object({
email: z.string().email(),
age: z.number().int().min(18).max(120),
role: z.enum(['admin', 'member']),
}).strict(); // .strict() rejects unknown keys
function validate(body) {
const result = CreateUser.safeParse(body);
if (result.success) return { value: result.data, errors: [] };
// Each issue carries a path array and a message; join the path for a field name.
const errors = result.error.issues.map((issue) => ({
field: issue.path.join('.'),
message: issue.message,
}));
return { value: null, errors };
}
Discussion