form validation

why

  • a form is the client edge of a contract; a contract with two vocabularies is not one contract.
  • client-only rules drift: client blesses what the server rejects; rejection arrives un-mappable — no field, no correction path.
  • one shared schema closes the loophole by construction and gives errors one grammar: field keys the form can place, honest form-level states for what only the server knows.
  • each new field inherits validation, error mapping, truthful failure states for free.

rule

  • validate through one named schema — the same contract the API uses — at every untrusted boundary.
  • schema is the single source of truth; the UI only mirrors it.
  • map field-keyed diagnostics onto fields, server-only verdicts onto honest form-level states.

validation contract

  • the form's shape is one named schema, the same vocabulary the API uses, validated at every untrusted boundary per zod boundaries.
  • what the client can check early, it checks early.
  • what only the server can know (uniqueness, permissions, provider state) stays a server verdict the form maps honestly.

layer map

layervalidatesshows
input eventsformat per field (shape, length, pattern).inline as the visitor leaves a field.
submitthe full schema, client-side.field errors before any network call.
API boundarythe same schema server-side, plus authority checks.typed field-keyed diagnostics.
domain serviceinvariants the schema cannot express.form-level error naming the blocking fact.
  • client and server share the one schema from the contract package — a divergent copy is the classic loophole (client passes, server rejects with a different message).
  • API errors arrive keyed by the schema's field names; the form maps them onto fields without translation.

error state design

an error state is a truthful product state:

  • names what failed and the correction path.
  • never claims the save succeeded, never invents success.
  • per field: message names the rule violated ("title is required", "end must be after start"), not the transport detail.
  • form-level failures the fields cannot fix (permission, conflict) state the blocking fact and the next action.
statesurface
field invalidmessage at the field, linked via aria-describedby, focus on submit failure.
submit rejected by serverfield errors mapped back; form-level banner only for form-level facts.
submit failed transientlyretryable state with retry, cause preserved.
successonly after the authoritative response — never optimistically.

do not

  • maintain a second validation vocabulary client-side when the contract schema exists.
  • show a success state before the authoritative response.
  • clear a visitor's input on failure — preserve input, mark errors.
  • validate visibly on every keystroke before the visitor finishes a field.
  • submit with unvalidated payload because "the server checks anyway".

verification

  • exercise each field's failure rule and each form-level failure: each shows its field-level or form-level state with a correction path, input preserved.
  • submit a payload that passes the client schema but fails a server-only rule: the returned diagnostic maps onto the right field.
  • keyboard through the error path: focus reaches the first error, messages are announced.
  • success state appears only after the response.

good and bad

bad

// client validation
if (!title) {
setError("title is required");
}
if (title.length > 120) {
setError("title too long");
}
await fetch("/api/workflows", { body: JSON.stringify(values) });
// server validation — different
if (!body.title || body.title.length > 100) {
return error("Title must be between 1 and 100 characters");
}
  • user passes client validation (119 chars), fetch succeeds, server rejects with "Title must be between 1 and 100 characters".
  • the form can't map it back — field error or server issue? client said 120 was ok. no correction path.
  • divergent schemas are the classic loophole.

good

const input = CreateWorkflowInputSchema.parse(values);
await createWorkflow(input);
  • same named contract validates before submit and again at the API boundary.
  • field keys arrive pre-mapped; server-only verdicts surface as honest form-level states.
  • the visitor always has a named rule and a correction path instead of an unexplainable rejection.

search pages

go to any page