implement
Validation

Validation

When a form validates, what it validates, and errors that come from elsewhere.

The schema is the only description of what valid means. Formish runs it over the whole input, then hands each issue to the field its path points at.

const SignUpSchema = v.object({
	email: v.pipe(v.string(), v.minLength(1, "Enter your email"), v.email("Enter a valid email")),
	password: v.pipe(v.string(), v.minLength(8, "At least 8 characters")),
});

An issue with no path — a check across two fields — becomes an error of the form itself, on form.errors.

When it runs

createForm({ schema, validate: "blur", revalidate: "input" });

validate is when a field first reports anything; revalidate takes over for a field that already has an error (and for every field once the form has been submitted). The default pair is submit and input: quiet until the first submit, then correcting as you type — which is the behaviour most forms want.

ModeValidates
initialOnce, as soon as the form is created
touchWhen a field is focused
inputOn every keystroke
changeOn the element's change event
blurWhen a field loses focus
submitOn submit only (validate default)

revalidate takes the same values except initial.

Validation runs over the whole form, so one field's blur reports every field's errors — including ones the user has not reached yet. That is usually what you want after a submit, and often too eager before one. To hold a message back until the field has been visited, gate it on isTouched:

const message = derived([field.errors, field.isTouched], (errors, touched) =>
	touched ? (errors?.[0] ?? null) : null,
);

If(message).Then(FieldError(message));

Where fields start

A field nobody has typed into holds nothing, which would make a required string fail as "expected string, received undefined" rather than with your own message. So formish walks the schema when the form is created and gives every field a starting value: "" for a string, [] for an array, null for a nullable. Write your messages for the empty case and they are what the user gets:

v.pipe(v.string(), v.minLength(1, "Enter your email"));

This comes from the schema, not from the DOM, so it does not depend on what is currently rendered. A field behind a collapsed section, on a tab nobody has opened, or with no Field written for it at all validates exactly like one that is on screen.

A field with no empty value to stand in for — a number, a boolean, a date — stays missing, and the schema reports it as such. Name one per type to change that:

createForm({
	schema: SignUpSchema,
	emptyInput: {
		number: 0, // required numbers start at 0 instead of missing
		boolean: false, // an untouched checkbox validates as unchecked
		string: undefined, // opt a type out entirely
	},
});

emptyInput is merged over the default, which is { string: "" } and nothing else, so naming number leaves the string default in place. Optional fields are never affected — they accept a missing value already.

NOTE

A required checkbox is the case worth knowing about: v.boolean() starts missing, so an untouched box validates as "expected boolean, received undefined". Either name boolean: false above, or write the field as v.optional(v.boolean(), false).

The schemas a form can be built from

Formish reads the schema itself, so it has to be one whose fields are known before anything runs: objects, arrays, tuples, intersections, unions, variants, v.lazy, and the optional/nullable wrappers around any of them. A union or variant shares one field per key across its branches, so a key only one branch carries is still addressable; where the branches disagree about a key, the last one wins.

Four have no fixed set of fields at all, and createForm throws rather than building a form that is quietly missing them: v.record, v.objectWithRest, v.tupleWithRest and v.promise.

Validating by hand

validate runs the schema whatever the mode says, and resolves with the result:

const result = await validate(form);
if (!result.issues) console.log(result.value);

Pass { shouldFocus: true } to move focus to the first field with an error, the way submitting does.

Async schemas

An async schema — a uniqueness check against a server, say — works the same, and form.isValidating is true while it runs:

const SignUpSchema = v.objectAsync({
	username: v.pipeAsync(
		v.string(),
		v.checkAsync(async (name) => !(await api.isTaken(name)), "Already taken"),
	),
});

Span(
	{ class: "hint" },
	form.isValidating.bind((busy) => (busy ? "Checking…" : "")),
);

Only the newest validation may write, so a slow check that settles after a newer one cannot bring back errors the user has already fixed.

Submitting

Form's onSubmit runs only if validation passed, and receives the schema's output — after every transform in it:

const Schema = v.object({
	age: v.pipe(v.string(), v.transform(Number), v.number()),
});

Form({ of: form, onSubmit: (output) => save(output) }, AgeField());
// output.age is a number, even though the field held a string

While the handler runs, form.isSubmitting is true — bind it to the button's disabled. If the handler throws, the message lands on form.errors instead of escaping, so a failed request can be rendered like any other error:

Span(
	{ class: "error" },
	form.errors.bind((errors) => errors?.[0] ?? ""),
);

Rendering the <form> element yourself instead of using Form? handleSubmit is the same wrapper:

FormElement({ noValidate: true, onSubmit: handleSubmit(form, onSubmit) }, AgeField());

And submit(form) submits from anywhere — a button outside the form, a keyboard shortcut.

Errors from the server

setErrors reports what a schema cannot know:

const result = await api.signUp(output);
if (result.error === "email-taken") {
	setErrors(form, { path: ["email"], errors: ["That email is already registered"] });
}

They behave like any other error — the field is invalid, form.isValid is false — and the next validation replaces them. Passing errors: null clears the ones a field has.

getErrors(form, { path }) reads one field's own errors once. For a field whose value is a shape of its own — an address, a tags input — the errors are on the fields inside it, and four readers walk down to them:

ReaderReturns
getDeepErrors(form, config?)Every message at or below the field, as one list
getDeepError(form, config?)The first of them, for a field that shows a single message
getDeepErrorEntry(form, ...)That first one with the path it came from
getDeepErrorEntries(form, ...) Every message paired with its path

The last is what an error summary at the top of a long form is built from — each entry links back to the field it belongs to. All four include the form's own errors, under an empty path.