Forms and the wire
Forms validate humans; wires validate applications. The bridge: validators as wire codecs and server issues onto fields.
Two validations live near each other here, and they are not the same thing. A form validates a human: values arrive as strings, get coerced, deserve progressive per-field feedback, and usually cover only a slice of the eventual input — the id comes from the route, the author from the session. The wire validates an application boundary: values arrive typed, complete, and possibly hostile. Collapsing the two into one schema is tempting and almost never right — the form wants “looks like an email while you type”, the wire wants “is a string, or 400”.
So result-rpc is not a form library and does not pretend the input codec is your form schema. Use a real one (we like Formisch — schema-first, headless, signal-fast). The contract contributes exactly the two edges that are its business:
Your validator can be the wire codec. If your team’s input vocabulary is
Valibot, Zod, or ArkType (the tRPC .input(z.object(...)) habit),
wire.standard adopts any synchronous Standard Schema
as a procedure’s input codec — validation on both sides of the wire, plus
the serializer preflight a plain validator can’t give you:
const rename = app.procedure()
.input(wire.standard(RenameInput)) // your Valibot/Zod schema, as the wire codec
.output(DocCodec)
.mutation()
(Async schemas are rejected — wire validation is synchronous — and the schema must accept its own output, so one-way transforms don’t fit. And when a form’s shape happens to coincide exactly with an input, sharing the schema is free — but treat that as a coincidence to notice, not an architecture to force.)
And the form runs the same schema first, with validateStandard — the
form-side companion to wire.standard. Per-field feedback before a request
exists, no ~standard spec plumbing:
import { validateStandard } from "result-rpc"
const validated = validateStandard(RenameInput, { id, title })
if (!validated.ok) return setFieldErrors(validated.fields) // dot-joined keys
await rename.mutate(validated.value)
Server rejections land on fields. Whatever validates the form, the
codec still validates the wire — and when a request fails there,
server/bad-request carries path-scoped issues that project onto field
keys:
const result = await rename.mutate(toInput(form.values))
if (!result.ok && result.error._tag === "server/bad-request") {
setFieldErrors(fieldIssues(result.error))
// { "title": ["Expected a string"], "author.email": ["Expected an email"] }
}
The paths are shaped like the input. When the form edits a projection of
the input — it usually does — map the keys where the shapes diverge, in the
same place you already map values (toInput above). The mapping is the
honest artifact: it is where “what the human edits” and “what the wire
carries” meet, and no bridge should hide it.
When does server/bad-request actually happen? Not from your own
same-version client submitting a bad value — the client encodes input through
the same codec before sending, and input the codec rejects throws a
TypeError at the call site (a programmer error; it never reaches the wire).
The wire rejection arc exists for callers whose codec disagrees with the
server’s: skewed clients mid-deploy, hand-rolled HTTP callers, other
services. So the division of labor is: your form validates the human
(validateStandard — see above) before mutate
is ever called; fieldIssues is the safety net for the wire’s own
validation, not your form’s primary path.
Mind the DefectShell. boundaryShells()’s defect ring claims
server/bad-request — under the recommended onion it escalates to the error
boundary and leaves the unions components see. A form that branches on it
must mount outside the defect shell’s subtree (or catch the claimed
rejection with isClaimed). If your form validates the human first, this
rarely matters — but it is a positional fact worth knowing before you wonder
where the tag went.