Skip to content
result-rpc
Esc
navigateopen⌘Jpreview

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.

Was this page helpful?