@k8ordo/form

Cross-field rules

Some checks depend on more than one field: a password confirmation, or “pick at least two”. No HTML attribute expresses them, and a .refine() is a function that cannot travel to the browser. @k8ordo/form has you declare them as rules beside the schema instead.

On this page

Declare a rule

Pass defineForm the schema and an array of rules.

schema.ts
export const signup = defineForm(
  z.object({
    password: z.string().min(8),
    confirm: z.string(),
  }),
  [sameAs('confirm', 'password', 'The passwords do not match')],
);

Pass the definition wherever you passed the schema: to formFields and to parseForm. Rules are plain data, so they travel to the browser along with the fields.

ts
const signupFields = formFields(signup);

const parsed = parseForm(signup, formData);

The field paths in a rule are checked against the schema’s types. Naming a field that does not exist fails to compile.

The available rules

Three rules are available.

  • sameAs(field, other, message): field must equal other. Use it to confirm a password.
  • minChecked(field, min, message): at least min of the checkboxes sharing the name must be checked.
  • requiredWhen(field, when, equals, message): field is required only while when holds equals.
schema.ts
defineForm(schema, [
  sameAs('confirm', 'password', 'The passwords do not match'),
  minChecked('topics', 2, 'Pick at least two'),
  requiredWhen('reason', 'status', 'rejected', 'Give a reason'),
]);
Playground

Try the rules

A talk review form. The reason is required only for “Reject”, and at least two aspects must be checked.

Aspects reviewed

Try it

  1. Check two aspects, choose “Reject”, leave the reason empty, and press “Submit”. The reason field shows an error.
  2. Switch back to “Accept”. The error on the reason goes away.
  3. Leave only one aspect checked and submit. The error asks for at least two.

The same verdict in the browser and on the server

One evaluator judges the rules on both sides: the browser against the live form, the server against the submission. There is no second implementation to drift from the first.

In the browser a broken rule is applied with setCustomValidity, so it looks exactly like a built-in check: :user-invalid matches, and the message arrives in the same error as every other one.

When several rules break on one field, the message of the one declared first is shown.

Word the message in the request’s locale

A rule’s message can be a function instead of a string. It is called when the rule is reported, not where the rule is declared.

schema.ts
export const signup = defineForm(schema, [
  sameAs('confirm', 'password', m.signup.mismatch),
  minChecked('topics', 2, () => m.signup.pickAtLeast(2)),
]);

The browser cannot call a function sent from the server, so formFields settles the wording as it derives the fields. To follow the locale, call formFields during the render.

Checks rules cannot express

Anything the three rules cannot express stays a .refine(), which runs on the server only.

schema.ts
z.object({
  start: z.iso.date(),
  end: z.iso.date(),
}).refine((value) => value.start <= value.end, {
  message: 'The end comes before the start',
});

A .refine() on the whole schema is listed in dropped as a check the browser does not run. Without a path, its error arrives in form.formError.

Warning

Pitfall

A .refine() on a single field, a nested object or a row is not listed in dropped yet. The browser lets it through without a word, and the error only appears once the server checks the submission.

k8ordo

React libraries that use Baseline features without holding back.

© 2026 k8o — MIT License

Typeset in Noto Sans JP & M PLUS 2