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
| layer | validates | shows |
|---|---|---|
| input events | format per field (shape, length, pattern). | inline as the visitor leaves a field. |
| submit | the full schema, client-side. | field errors before any network call. |
| API boundary | the same schema server-side, plus authority checks. | typed field-keyed diagnostics. |
| domain service | invariants 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.
| state | surface |
|---|---|
| field invalid | message at the field, linked via aria-describedby, focus on submit failure. |
| submit rejected by server | field errors mapped back; form-level banner only for form-level facts. |
| submit failed transiently | retryable state with retry, cause preserved. |
| success | only 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 validationif (!title) {setError("title is required");}if (title.length > 120) {setError("title too long");}await fetch("/api/workflows", { body: JSON.stringify(values) });
// server validation — differentif (!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.