Examenzaal is a practice-exam site for Dutch exams. The real exams mix question types: plain multiple choice, "tick all that apply", a grid where every row gets one column, a typed number, putting items in order, dragging items onto targets, and clicking a region on a picture. Our scoring code has to handle all of them, and it has to score the way the exam bodies do, where they publish how they score. Here is how that ended up as one small function, and the handful of rules that keep it honest.
One union for the answer key, one for the answer
Every question in an assembled exam carries an answer key, and the key is a tagged union with seven type tags:
export type AnswerKey = | { type: "single_choice"; correctOptionId: string } | { type: "numeric"; min: number; max: number } | { type: "numeric"; values: number[] } | { type: "sleep"; placements: Placements } | { type: "hotspot"; correctRegionId: string } | { type: "multi_select"; correctOptionIds: string[] } | { type: "matrix"; cells: Cells } | { type: "ordering"; order: string[] }; (sleep comes from slepen, Dutch for to drag: a sleepvraag is a drag-and-drop question. The numeric type has two shapes, a range or a list of accepted values.)
The candidate's answer is a second union with its own shapes: an option id, a number, a map of placements, a region id, a list of option ids, a map of cells, the text as typed, or an ordered list of ids. isCorrect(key, answer) switches on the key's type and checks that the answer has the matching shape. A mismatched shape is simply wrong, never an exception.
All or nothing, per question
A comment in the scoring file states the rule bluntly: no negative marking and no partial credit. A question earns its full points or nothing. The comment in the code says this follows how the CBR, SVH and Marifonie theory exams score, and that it applies to a drag question with several items too: half the items in place earns nothing. The same rule is used for tick-all and matrix questions; the exam body for those doesn't publish whether it gives partial credit, so there all-or-nothing is our choice.
In practice that makes each case short:
- Tick all that apply: the set of ticked options must equal the key's set. One missing or one extra makes the question wrong.
- Matrix: every row must have the key's column, and the answer must not have a row the key doesn't have.
- Drag: the placed items must be exactly the key's. No distractor may be placed.
- Ordering: the whole order must match. One item out of place and the question is wrong.
The empty-key bug it is built to avoid
Look at the drag case and the matrix case in the code. Both return expressions begin with want.length > 0. The comment in the drag case explains why: "An empty key would make an empty answer right."
If a question's key were ever empty, then an empty answer would have the same size as the key and every key entry would be satisfied vacuously. A candidate who touched nothing would score full marks. The same guard sits in the tick-all case (correctOptionIds.length > 0) and the ordering case (key.order.length > 0).
The scorer refuses to call such a question right. For drag questions the same comment points at a second layer, sleepProblems, as the check that rejects such a question outright. Two layers, because the failure is silent and flattering: nobody complains when a practice exam gives them a point.
"Not answered" is its own state
Results carry both correct and answered. isAnswered treats a drag question with nothing placed, a tick-all question with nothing ticked, a matrix with no row chosen, an empty typed answer and an empty order as not answered. A caller can therefore tell "wrong" from "skipped" instead of lumping them together.
Typed numbers: refuse to guess
Some questions ask for a number. Dutch notation uses a decimal comma, but people type points too. parseDutchNumber accepts 12,5 and 12.5. What it will not do is guess:
- A point followed by exactly three digits after a non-zero whole part is refused as ambiguous.
1.250is 1250 in Dutch notation and 1.25 in English. - A number with both a point and a comma, or with spaces inside, is refused.
A refused entry is scored as wrong rather than interpreted. The comment says why: thousands separators are never needed, because candidates type 1250. Comparison uses a tolerance of 1e-9, and a key can be a range or a list of up to ten accepted values.
Ordering questions have a validation rule too
An ordering question has three to six items. The validation function checks that item ids are unique and that item texts differ, and that the correct order lists every item exactly once. It also rejects a question whose items are listed in the right order already: "list them shuffled". The items are shown in the order the drafter lists them, so a question that is already in the right order would hand out its points for doing nothing.
What this buys us
One function, one switch, and a rule that fits in one sentence. When we add a question type, the scorer itself changes in three places: the key union, the answer union, and the case in isCorrect.
The scorer also stays boring on purpose. It doesn't know about slots, forms or pass marks. Those live in other functions, and formScoring takes the points a form is worth and returns the pass mark for it.
If you want to see the question types in practice, the exam overview lists what we cover, and how we work explains how the questions are written and checked by other AI models.

