Lesson 18 / 22
Validation Schemas
Gathering acceptance rules into a single declaration, transforming raw input into a parsed value, the same schema producing the same result on the client and the server, and keeping the error code separate from the message.
Contents
The previous lesson held the form’s values but did not look at whether they are acceptable. The measurement value must be a number, must fall within a reasonable range for its unit, the date must not be in the future, and the station code must match its defined format.
These rules need to be known in two places. On the client, to give the user feedback without going to the server. On the server, because — as the Form Events lesson said — a request can be built without the page ever being used; client-side validation is a convenience, not a guarantee. Writing the same rule separately in both places means the two will drift apart over time: the client accepts, the server rejects — or the reverse, which is worse.
A Schema Is a Declaration
Writing validation as nested conditionals works, but it makes three things impossible: listing the rules, running the rules in another environment, and declaring the rules to the interface. If learning whether a field is required requires reading code, that fact cannot be wired to the “required” mark on the screen.
A validation schema writes the rules as data: which rules apply to which field lives in a structure, separate from the function that applies them. This yields three things. The schema is portable — the same structure can be interpreted in another runtime. It is queryable — the interface reads which field is required from the schema. And it is composable — shared field rules are defined once and used across multiple forms.
This does not replace constraint validation from the Web Fundamentals and HTML course; it builds on top of it. Constraints declared in markup run the browser’s own mechanism and work even if the page’s script never loads. The schema’s scope is broader: cross-field rules that cannot be declared in markup, ranges that change by unit, a codebase shared with the server.
Not Just Validating — Parsing
A validation function usually returns true or false for “is it valid?” This forces the calling code to redo the same conversion a second time: if the value is valid, convert the text to a number, parse the date, trim the whitespace. A small difference between the two conversions leads the validated value and the used value to diverge.
The more robust approach is for validation to return the parsed value. The rule chain
takes the raw input, transforms it at each step, and produces a ready-to-use, typed value at
the end. If the field is invalid, no value is produced; if it is valid, the produced value now
carries the rules’ guarantee. This also makes it possible for both sides to reach the same
result: even when the client supplies the string "-4,2" and the server supplies the number
-4.2, the chain’s output is identical.
The Same Schema on Both Sides
// validation-schema.mjs — the same schema on both the client and the server const NOW = Date.parse("2026-01-14T12:00:00Z"); // fixed for the example // --- Rule library: every rule takes the raw value, returns a value or an error const RULES = { required: (raw) => raw === undefined || raw === null || String(raw).trim() === "" ? { error: "required" } : { value: raw }, pattern: (raw, regex) => new RegExp(regex).test(String(raw)) ? { value: String(raw) } : { error: "format" }, number: (raw) => { const n = typeof raw === "number" ? raw : Number(String(raw).replace(",", ".")); return Number.isFinite(n) ? { value: n } : { error: "not_a_number" }; }, option: (raw, allowed) => allowed.includes(String(raw)) ? { value: String(raw) } : { error: "invalid_option" }, date: (raw) => { const t = Date.parse(String(raw)); return Number.isNaN(t) ? { error: "not_a_date" } : { value: t }; }, maxLength: (raw, n) => String(raw ?? "").length <= n ? { value: String(raw ?? "") } : { error: "too_long" }, }; const SCHEMA = { fields: { station: [["required"], ["pattern", "^NS-\\d{2}$"]], date: [["required"], ["date"]], value: [["required"], ["number"]], unit: [["required"], ["option", ["C", "F"]]], note: [["maxLength", 120]], }, crossFields: [ { name: "range", requires: ["value", "unit"], check: (v) => { const [min, max] = v.unit === "F" ? [-76, 140] : [-60, 60]; return v.value < min || v.value > max ? { path: "value", code: "out_of_range", param: { min, max } } : null; }, }, { name: "notFuture", requires: ["date"], check: (v) => (v.date > NOW ? { path: "date", code: "in_future" } : null), }, ], }; function validate(schema, input) { const value = {}, errors = []; for (const [field, rules] of Object.entries(schema.fields)) { let raw = input[field]; let broken = false; for (const [name, param] of rules) { const result = RULES[name](raw, param); if (result.error) { errors.push({ path: field, code: result.error, param }); broken = true; break; } raw = result.value; // the parsed value feeds the next rule } if (!broken) value[field] = raw; } for (const cross of schema.crossFields) { if (!cross.requires.every((f) => f in value)) continue; // skipped if input is missing const error = cross.check(value); if (error) errors.push(error); } return { valid: errors.length === 0, value, errors }; } const log = (name, result) => { console.log(`${name}`); console.log(` valid : ${result.valid}`); console.log(` value : ${JSON.stringify(result.value)}`); console.log(` errors: ${JSON.stringify(result.errors)}`); }; // --- Same record, two different shapes -------------------------------------- // Client: every value comes from a field as a string. const clientInput = { station: "NS-01", date: "2026-01-14", value: "-4,2", unit: "C", note: "morning reading", }; // Server: the JSON body sends numbers as numbers, strings as strings. const serverBody = { station: "NS-01", date: "2026-01-14", value: -4.2, unit: "C", note: "morning reading", }; log("client input (strings)", validate(SCHEMA, clientInput)); log("server body (JSON)", validate(SCHEMA, serverBody)); const a = JSON.stringify(validate(SCHEMA, clientInput)); const b = JSON.stringify(validate(SCHEMA, serverBody)); console.log("did both sides produce the same result:", a === b); // --- A request that never went through the form ------------------------------ log("\na request built by skipping the page", validate(SCHEMA, { station: "XX-9", date: "2027-03-01", value: 900, unit: "K", note: "x".repeat(200), })); // --- The 422 body built from the error list ---------------------------------- const result = validate(SCHEMA, { station: "NS-01", date: "2026-01-14", value: "88", unit: "C" }); const body422 = { code: "validation", fields: result.errors.reduce((t, e) => ({ ...t, [e.path]: { code: e.code, param: e.param } }), {}), }; console.log("\n422 body:", JSON.stringify(body422));
client input (strings)
valid : true
value : {"station":"NS-01","date":1768348800000,"value":-4.2,"unit":"C","note":"morning reading"}
errors: []
server body (JSON)
valid : true
value : {"station":"NS-01","date":1768348800000,"value":-4.2,"unit":"C","note":"morning reading"}
errors: []
did both sides produce the same result: true
a request built by skipping the page
valid : false
value : {"date":1803859200000,"value":900}
errors: [{"path":"station","code":"format","param":"^NS-\\d{2}$"},{"path":"unit","code":"invalid_option","param":["C","F"]},{"path":"note","code":"too_long","param":120},{"path":"date","code":"in_future"}]
422 body: {"code":"validation","fields":{"value":{"code":"out_of_range","param":{"min":-60,"max":60}}}}
Reading the Output
The first two blocks produced the same parsed value, and the comparison came back true. The inputs were different: the client supplied a string using a comma as its decimal separator, the server supplied a number. The chain converted both to the same number. The date also resolved to the same timestamp on both sides. This equality is the practical payoff of the schema being a single source: the client accepting a value the server rejects can only happen if the schema has drifted apart.
The third block shows a request that skipped the form, and four errors are reported at once.
Notice that the rule chain stops at the first error: because the station field got
stuck on the pattern rule, the rules after it never ran, and the field produced no parsed
value. The reason is that a later rule relies on an earlier rule’s output; a range rule that
mistook a raw value for a number would produce a meaningless error.
There is a deliberate gap in the same block: even though value is 900, no range error is
reported. That is because the cross-field rule requires the unit field to be resolved, and
unit stayed invalid with the value “K”. Cross-field rules are skipped when their inputs
are missing. The alternative — using a default value for a missing input — produces
misleading errors: the reported range could change once the user fixes the unit. The request
is already rejected; the range error is reported only once the user fixes the unit and
resubmits.
The last line closes the loop: on the server, the error list from the same schema turns
directly into the body of the 422 response. In the REST Client lesson, this body was carried
in a detail field; in the Form State Management lesson, it was matched by field name and
attached to the form. Having the same field names on both sides is this chain’s precondition.
Not Every Rule Can Run on Both Sides
Sharing the schema does not mean every rule can be shared. Three classes need to be distinguished.
Rules that run on both sides look at the input itself: format, type, length, range, consistency between fields. These form the body of the schema.
Rules that can only run on the server look at the system’s state: whether the station code is registered, whether a measurement for that date and station has already been entered, whether the user has write authorization for that station. The client does not know these, and should not. Their counterpart on the client is showing the field error that comes back from the server.
Rules meaningful only on the client look at the input’s presentational side: a field’s length counter, a warning for input that is not finished yet. These do not block submission.
There is also an intermediate class that asks the server a question: querying whether a station code exists while the user types, for instance. This is validation becoming asynchronous, and it requires three rules. The query is throttled with debounce. A late-arriving response is filtered out by the stamp rule from the Loading and Error States lesson. Submission goes ahead rather than waiting on a pending query, and trusts the server’s decision — asynchronous validation is a convenience, not a gate.
An Error Code, Not a Message
The errors the schema produces carry a code and a parameter, not text. This separation is required for three reasons.
Text is language-dependent. The same error code has a different rendering in Turkish and in English; the place that produces the code does not need to know the language. The Internationalization lesson builds this mapping.
Text is context-dependent. The same required code is written as “This field is
required” next to the field, and as “Measurement value was not entered” in the error summary.
Using one piece of text in both places breaks one of them.
Text is parameter-dependent. A range error carries its limits as a parameter; the message is written by substituting them in. When a limit changes, the schema changes, not the text.
Naming the codes is itself a contract. The client and the server must use the same set of codes; if the server sends a code the client does not recognize, the client should show a generic message and never print the code to the screen.
Summary
- A validation schema writes rules as data; this makes the rules listable, queryable, and runnable in another environment.
- The schema does not replace the constraint validation declared in markup; it adds the cross-field and state-dependent rules that markup cannot cover.
- The rule chain does not just validate; it converts raw input into a parsed value, so that the client’s string and the server’s number arrive at the same result.
- The chain stops at the first error, because a later rule relies on an earlier rule’s output; cross-field rules are skipped when their required inputs are not resolved.
- Rules fall into three classes: those that run on both sides, those that can only run on the server, and those meaningful only on the client. Asynchronous validation is a convenience; it does not stop submission.
- An error code and a parameter are carried, not a message; text depends on language, context, and parameters, and is produced in the presentation layer.
Next Step
There is now an error code and a parameter for every field. The next question is not how these appear on the screen — it is how they get heard. A user who is not looking at the screen never sees the red outline; when the error message is written below the field, a screen reader does not automatically read it together with the field. Where focus goes when a submission is rejected, how the error count is announced, and how the message gets tied to the field all call for separate decisions. The next lesson builds that connection.
To keep your progress and take notes, Log in
My notes
Log in to take notes.