Skip to content

Validation

Use a Standard Schema-compatible schema when you want one source of truth for validation and submit value types. Schema issues with field paths are mapped back onto registered fields on submit.

import { z } from 'zod';
import { Form, InputField, PasswordField, SubmitButton } from 'solid4m';
const loginSchema = z.object({
email: z.string().email('Enter a valid email'),
password: z.string().min(8, 'Password must be at least 8 characters')
});
<Form
schema={loginSchema}
onSubmit={(values) => {
values.email; // string
values.password; // string
}}
>
<InputField name='email' label='Email' />
<PasswordField name='password' label='Password' />
<SubmitButton>Log in</SubmitButton>
</Form>;

Use useForm({ schema }) for the same inference when you need state outside the form tree.

Field-level constraints are still available as field props. Errors appear after the user has blurred a field or submitted the form.

<InputField
name='username'
label='Username'
required
minLength={3}
maxLength={20}
pattern={/^[a-z0-9_]+$/}
/>
<InputField name='age' label='Age' min={18} max={120} />
<InputField name='confirm' label='Confirm password' match='password' />
Constraint Type Description
required boolean Field must have a non-empty value
minLength number Minimum measured length
maxLength number Maximum measured length
min number Minimum numeric value, parsed with the field parse prop
max number Maximum numeric value, parsed with the field parse prop
pattern string | RegExp Value must match the pattern
match string Value must equal the named field’s current value
step number | 'any' Value must land on a step, counted from min
type string The input’s own type; format-checks email and url

Set a constraint prop to false to disable that constraint entirely, for example required={false}.

Only required decides whether a field must have a value. minLength, maxLength, min, max, pattern and type all skip an empty value (undefined, null or '') rather than failing it, so an optional bounded field is valid while untouched and an empty required field reports one error instead of two. minLength, maxLength, min and max also skip an empty array. Combine them when a field must be both present and bounded:

<InputField name='username' label='Username' required minLength={3} />

minLength/maxLength measure whatever the field holds: a string by its characters, an array (for example a multi-select) by its item count, and a number (from a custom parse) by its digits. The number case measures the parsed value, so it is not a stand-in for bounding the typed text — a parse to a number drops leading zeros (01234 measures 4, not 5) and counts a minus sign. Keep the default string parse when the characters the user typed are what must be bounded.

type='email' and type='url' are format-checked from the type attribute itself — no extra prop:

<InputField name='email' type='email' label='Email' required />
<InputField name='website' type='url' label='Website' />

An invalid value reports "Email" must be a valid email address or "Website" must be a valid URL. Both follow the HTML specification exactly, so they accept and reject the same values a browser does — including the surprising ones: a@b is a valid email address (there is no TLD requirement), and type='url' requires an absolute URL, so example.com fails but mailto:a@b.com passes. Like every other constraint, an empty value is left to required.

Every other input type is unchecked, type='number' included — a number input’s own value handling already discards non-numeric text before this library sees it, so required is what catches an empty one. A multiple email input’s comma-separated list is not supported; use a custom validator.

step checks that a value lands on one of the allowed increments, exactly as the browser used to:

<InputField name='quantity' type='number' min={1} step={5} />
<InputField name='reminder' type='time' step={1800} />

The first accepts 1, 6, 11 — min is where the ladder starts, so it is not multiples of 5. Without a min the ladder starts at zero. The second accepts times on the half hour.

step is counted in the units the HTML specification defines for the input’s type, which are not always the units the field displays: one step is 1 on a number, one day on a date, one month on a month, one week on a week, and one second on time and datetime-local. That is why the reminder above is 1800 and not 30. Every other type ignores step entirely, as a browser does — including range, whose value snaps to the nearest step instead of ever being invalid.

Pass step='any' to render the attribute while turning the check off.

An absent step is not checked, which is a deliberate difference from the browser. A bare <input type='number'> has a default step of 1, so a browser rejects 19.99 in a price field that never asked for stepping; reproducing that would invalidate decimal number fields everywhere at once. Write step={0.01} to opt in.

min and max use the native DOM format for date/time inputs and are validated by the library as well as rendered for the picker:

<InputField name='appointment' type='date' min='2026-01-01' max='2026-12-31' />
<InputField name='reminder' type='time' min='09:00' max='17:00' step={1800} />

The same applies to month, week, and datetime-local. Numeric inputs keep using numeric bounds, such as min={18} and max={120}. A malformed or mismatched bound is skipped rather than creating a false validation error.

The rendered <form> sets noValidate, because the browser’s own interactive validation would otherwise intercept the submit and prevent this library’s validation, error display, and focus management from running. Constraint props are still rendered as real HTML attributes, so required still maps to aria-required for assistive tech, step still drives the spinner arrows and the native picker, and the email/url format checks and step above replace the validation the browser would have applied.

One piece of native behavior has no library equivalent yet: a raw <input required> written directly inside <Form>, rather than through a field component, is not registered as a field and so is validated by nobody.

Use a validator function for logic that built-in constraints cannot express. Custom validators run only when no built-in errors exist on the field.

<InputField
name='username'
label='Username'
validator={async (name, value, formState, setErrors) => {
const taken = await api.checkUsername(value as string);
if (taken) {
setErrors(['Username is already taken']);
}
}}
/>

The validator signature is:

type CustomValidator<M, N extends FieldPath<M>> = (
fieldName: N,
fieldValue: FieldPathValue<M, N>,
formState: FormState<M>,
setFieldErrors: (errors: string[]) => void
) => void;

Sync validators call setFieldErrors before returning. An async validator (an async function is fine) calls it once its work resolves; if the field changes again in the meantime, the stale result is discarded rather than applied.

A field’s errors do not render the moment they exist — a required field is technically “invalid” the instant it mounts empty, and showing that on first paint (or on the first keystroke of an email address) would just be shouting at someone who has not finished answering yet. An error is displayed once either of these becomes true for that field:

  • It has been blurred. Leaving the field is the signal that the user considers their answer final, at least for now, so whatever is wrong with it is worth surfacing.
  • It has been valid before. Once a field has held a valid value, subsequent edits are shown live, with no need to blur again. At that point the user has already demonstrated they can produce a correct answer, so silently withholding feedback while they change it is the opposite of what a field they are “still fixing” needs — this is what makes typing a valid username and then deleting a character immediately show “too short” again, instead of waiting for another blur.

A submit reveals every field’s errors at once, including on fields nobody has touched yet, so a pristine required field is not the one silent exception to an otherwise-explained failure.

Try it below: the username needs at least 3 characters. Type one or two and nothing appears — it has never been valid and you have not left the field. Type a third character and its inspect panel turns valid — the same moment the field’s hasBeenValid flag latches true — then delete a character again and the error reappears immediately, with no blur required, because that flag never resets. The email field demonstrates the other trigger: type an invalid address and blur away, and inspect flips its blurred tag and the error appears on that blur alone, with the field never having been valid at all.

import { SubmitButton, createForm } from 'solid4m';
interface SignupValues {
username: string;
email: string;
}
const { Form, InputField } = createForm<SignupValues>();
function SignupForm() {
return (
<Form onSubmit={(values) => console.log(values)}>
<InputField name='username' label='Username' required minLength={3} />
<InputField name='email' type='email' label='Email' required />
<SubmitButton>Submit</SubmitButton>
</Form>
);
}