subroutine — form discipline
Apply this to a form's schema and the components that bind it. The nearest
AGENTS.md picks the form, validation, design-system and i18n libraries and
wins; the APIs below assume Zod >= 4.1 and React Hook Form. A schema no form
binds is out of scope.
Convert in the schema, nowhere else
- An
<input> holds a string: convert in a codec, never in the form library
(valueAsNumber, setValueAs) or by hand. Two converters disagree on what
empty means — valueAsNumber yields NaN, which z.number() rejects with an
untranslated developer string.
- Split the codec: the input schema owns the shape, so
decode stays a bare
Number; the exported value schema owns the range, so -1 is told to be
positive instead of unreadable.
- Blank, malformed and out-of-range are three failures with three messages, and
abort after blank. Use the library's format constants, never a copied regex.
- Mirror the contract's bounds: a form looser than its API turns an inline
message into a failed request.
- Route messages through the repo i18n as thunks (
{ error: () => m.key() }) —
a plain string in a module-scope schema freezes the locale active at import.
- A closed option set (Select, Switch, radio) parses nothing: convert in the
control's
onChange.
// `abort` matters: without it a blank field fails both checks and reports
// "invalid" for a value the user never entered.
const slaveId = z.codec(
z
.string()
.trim()
.min(1, { error: errors.blank, abort: true })
.regex(z.regexes.integer, { error: errors.invalid }),
SlaveIdSchema, // exported: z.int().min(1).max(247), reused on stored data
{ decode: Number, encode: String },
);
Input and output are different types
- Wire both into the hook (
useForm<FormInput, unknown, FormOutput>) and name
them by direction, not after the form.
- Form state speaks input:
watch, getValues, setValue and field.value
see the raw string. Converted values exist at submit and nowhere earlier.
- Seed an edit form through encode, field by field: encoding a whole object
blanks the entire prefill over one unrelated invalid stored value.
safeParse on a codec is the decode direction, so it rejects the very value
the API returns. Validate stored data against the exported value schema.
Seed a required field blank, never absent
- A schema shared by form variants marks each variant's fields optional, so
absent is how the other variants submit. It cannot also mean "untouched".
- The binding decides which of the two an untouched field lands on, not the
schema: a registered input seeds from the DOM as
"", a controlled one stays
undefined. Same blank field, required or optional by accident of wiring.
- Seed every required field with its empty value in the defaults, per variant,
and reseed on a variant switch — a value left by the previous variant is still
validated, from behind an input nothing renders.
- Do this before removing a submit gate: a controlled required field reads valid
while empty, so the freed button throws inside the submit handler and floats
the rejection.
Let the submit run
- Disable submit while a submission is in flight, never on validity. An invalid
submit is what publishes the whole error map and focuses the first offender;
the validity flag is recomputed without writing errors, so gating on it
removes the only remedy, on a button that announces nothing.
- Gating on dirtiness is a different rule and a legitimate one.
- Map a rejected submit back onto the field that caused it (
setError) or a
form-level alert. A toast that vanishes is not an error message.
1---2name: form-rules3description: Form discipline for TypeScript — the schema is the only converter, input and output are different types, a required field is seeded blank not absent, submit is never gated on validity, and each failure names itself.4---56# subroutine — form discipline78Apply this to a form's schema and the components that bind it. The nearest9`AGENTS.md` picks the form, validation, design-system and i18n libraries and10wins; the APIs below assume Zod >= 4.1 and React Hook Form. A schema no form11binds is out of scope.1213## Convert in the schema, nowhere else1415- An `<input>` holds a string: convert in a codec, never in the form library16 (`valueAsNumber`, `setValueAs`) or by hand. Two converters disagree on what17 empty means — `valueAsNumber` yields `NaN`, which `z.number()` rejects with an18 untranslated developer string.19- Split the codec: the input schema owns the shape, so `decode` stays a bare20 `Number`; the exported value schema owns the range, so `-1` is told to be21 positive instead of unreadable.22- Blank, malformed and out-of-range are three failures with three messages, and23 `abort` after blank. Use the library's format constants, never a copied regex.24- Mirror the contract's bounds: a form looser than its API turns an inline25 message into a failed request.26- Route messages through the repo i18n as thunks (`{ error: () => m.key() }`) —27 a plain string in a module-scope schema freezes the locale active at import.28- A closed option set (Select, Switch, radio) parses nothing: convert in the29 control's `onChange`.3031```ts32// `abort` matters: without it a blank field fails both checks and reports33// "invalid" for a value the user never entered.34const slaveId = z.codec(35 z36 .string()37 .trim()38 .min(1, { error: errors.blank, abort: true })39 .regex(z.regexes.integer, { error: errors.invalid }),40 SlaveIdSchema, // exported: z.int().min(1).max(247), reused on stored data41 { decode: Number, encode: String },42);43```4445## Input and output are different types4647- Wire both into the hook (`useForm<FormInput, unknown, FormOutput>`) and name48 them by direction, not after the form.49- Form state speaks input: `watch`, `getValues`, `setValue` and `field.value`50 see the raw string. Converted values exist at submit and nowhere earlier.51- Seed an edit form through encode, field by field: encoding a whole object52 blanks the entire prefill over one unrelated invalid stored value.53- `safeParse` on a codec is the decode direction, so it rejects the very value54 the API returns. Validate stored data against the exported value schema.5556## Seed a required field blank, never absent5758- A schema shared by form variants marks each variant's fields optional, so59 absent is how the other variants submit. It cannot also mean "untouched".60- The binding decides which of the two an untouched field lands on, not the61 schema: a registered input seeds from the DOM as `""`, a controlled one stays62 `undefined`. Same blank field, required or optional by accident of wiring.63- Seed every required field with its empty value in the defaults, per variant,64 and reseed on a variant switch — a value left by the previous variant is still65 validated, from behind an input nothing renders.66- Do this before removing a submit gate: a controlled required field reads valid67 while empty, so the freed button throws inside the submit handler and floats68 the rejection.6970## Let the submit run7172- Disable submit while a submission is in flight, never on validity. An invalid73 submit is what publishes the whole error map and focuses the first offender;74 the validity flag is recomputed without writing errors, so gating on it75 removes the only remedy, on a button that announces nothing.76- Gating on dirtiness is a different rule and a legitimate one.77- Map a rejected submit back onto the field that caused it (`setError`) or a78 form-level alert. A toast that vanishes is not an error message.