When a practice sitting finishes, the browser posts the result, and since we added answer reviews that payload includes a new field: what you answered, as ids and choices, destined for a jsonb column. I wrote about the report rebuilding itself from that column and about why losing the column has to be cheaper than losing the score.
This post is about the fifty lines that stand between a client POST and that column, because jsonb has a property worth saying out loud: there is no column type to hide behind. varchar(200) argues with you. jsonb takes a megabyte of nested arrays without complaint, from anyone who can authenticate.
The bounds are not round numbers
/**
* Validation for the saved answer-review responses (SessionResponses in
* ./types.ts), applied by POST /api/games/sessions. Bounded so a session row
* stays small: the largest sittings are a Verbal or Numerical test of a few
* dozen items and a checking part of under a hundred.
*/
const index = z.number().int().min(0).max(7);
const id = z.number().int().nonnegative();
const text = (max: number) => z.string().max(max);
Three leaf schemas, named after what they are. index is a position among the options of a question, and the most options any of these parts puts on screen is eight, so the valid range is 0 to 7 and not nonnegative(). id is a bank id. text(n) exists so that every string in the schema has to state its own ceiling at the point of use, rather than inheriting a default nobody chose.
Then every array says how long it is allowed to be:
z.object({
kind: z.literal('casework'),
items: z
.array(z.object({ packId: id, statementId: id, answer: z.number().int().min(0).max(4) }))
.min(1)
.max(100),
}),
.min(1) as much as .max(100). An empty array is not a smaller version of a valid payload, it is a payload with nothing in it, and accepting it would mean writing a row whose review renders as an empty section. If there is nothing to review, the field should be absent, and the builders upstream return null rather than an empty shape for exactly that reason.
The numbers come from the content. The longest sittings we author are a few dozen items, so the caps are 50 for the scenario parts, 100 for the longest list of statements, and 500 for a timed checking part where the records are generated rather than authored. A cap of 1,000 "to be safe" is a cap that permits a row twenty times larger than anything legitimate, which is the opposite of safe.
Two nested discriminated unions, which is the content model showing through
export const responsesSchema = z.discriminatedUnion('kind', [
z.object({ kind: z.literal('cs-battery'), sections: z.array(sectionSchema).min(1).max(6) }),
z.object({
kind: z.literal('cs-adaptive'),
items: z
.array(z.object({ itemId: id, answer: text(200), trial: z.boolean() }))
.min(1)
.max(100),
}),
]);
The outer union is the kind of test: a battery made of several parts, or an adaptive single-format test. sectionSchema is itself a discriminatedUnion('kind', ...) over the part shapes, because a question is not always the unit in this product and a part that asks you to rate four actions saves something structurally different from a part that asks for one best answer.
discriminatedUnion rather than union matters here for a practical reason beyond speed: the failure message. A plain union tries every member and reports every failure, so a one-character mistake in a 50-item payload produces an error listing five unrelated shapes. A discriminated union reads kind, picks one member, and tells you what was wrong with the shape you were actually sending.
The field is allowed to be wrong, and the request still succeeds
This is the line I like most in the whole feature:
// The answer review's saved responses. A payload that fails validation is
// dropped rather than failing the request: losing the review is far better
// than losing the score.
responses: responsesSchema.optional().catch(undefined),
.catch(undefined) turns a validation failure into an absent field instead of a rejected request. Every other field in that schema is strict, because a score we cannot parse is not a score. The review is a nicety on top, and a user who has just spent 45 minutes on a practice sitting should not lose the result because a new, content-shaped, client-serialised blob had an off-by-one in it.
That asymmetry is worth designing on purpose rather than discovering. Within one request body there are fields whose failure must be fatal and fields whose failure must be silent, and zod can express both. A schema where everything is strict is a schema that has ranked nothing.
It pairs with something else about this endpoint: our client-side fetch retries POSTs, and a primary key is the only reason that is safe. A dropped review field does not make a retry produce a different row.
The one thing the schema refuses to check
A payload can be perfectly valid and still be nonsense: itemId: 4096 when no such item exists, or a rating against a scenario that was deleted last week.
Validating that would mean reading the question bank at write time, on a path that currently touches nothing but the database, and it would mean a stored row becoming retroactively invalid every time we edit content. We edit content constantly. A referential integrity check against a mutable JSON library is a check that lies about whether the write succeeded.
So the schema bounds the shape, and resolution happens at read time, where the builder simply skips anything that no longer matches and returns null when nothing does. The division of labour: the write path protects the database, the read path protects the reader.
We are selective about where zod earns its place. We took it out of the login page because a password rule written twice is a password rule that will eventually disagree with itself. A nested, discriminated, bounded blob arriving from a browser is the other end of that spectrum, and is the case the library is actually for.
Where the payloads come from
These are the sittings that produce them: the eight UK Civil Service online tests, including the Customer Service Skills Test whose checking part is the one with generated records and therefore the tightest caps in the schema. The test format pages are the vendor-neutral tour of those formats, and the Scores and Reports upgrade is what turns a saved row into the report the column exists for.
Top comments (0)