Overview

schema((obj) => definition) builds a reusable async validator for plain objects. Always await. The factory receives the current object so fields can close over siblings (isEqualTo(() => obj.password)).

validateObject(data, node) is the engine — schema() calls it. Nest schemas as fields, or use combinators: optional, validateOnlyIf, validateEach, validateTuple.

validateAll is the FormAtom walker (sync) — different track. Do not pass FormAtoms into a schema.

Helpers

API
Input
Async?
schema / validateObject Plain snapshot Yes — always await
validateAll FormAtom tree Sync only
validateCustom Imperative errors bag Sync or async cb

schema() — shared client / server

atom holds the snapshot. Same function on the server with body.

import { html, atom, schema, isRequired, isEmail, minLength, isEqualTo, flattenErrors, renderApp } from "mates";

const userSchema = schema((user) => ({
name: [isRequired(), minLength(2)],
email: [isRequired(), isEmail()],
password: [isRequired()],
confirm: [isRequired(), isEqualTo(() => user.password, "Must match password")],
}));

const App = () => {
const data = atom({ name: "", email: "", password: "", confirm: "" });
const result = atom(null);

const setField = (key) => (e) => {
data.update((d) => { d[key] = e.target.value; });
};

return () => html`
<div class="m-col m-gap">
<input placeholder="Name" .value=${data().name} @input=${setField("name")} />
<input placeholder="Email" .value=${data().email} @input=${setField("email")} />
<input type="password" placeholder="Password" .value=${data().password} @input=${setField("password")} />
<input type="password" placeholder="Confirm" .value=${data().confirm} @input=${setField("confirm")} />
<button class="btn-primary" @click=${async () => result.set(await userSchema(data()))}>
userSchema(data())
</button>
${result()
? html`<pre class="muted m-t-5">${JSON.stringify({
isValid: result().isValid,
errors: result().errors,
flat: flattenErrors(result().errors),
}, null, 2)}</pre>`
: ""}
</div>
`;
};

Nested schemas — validateEach + optional

Pass a schema as a field. validateEach applies it to every array item.

import {
html, atom, schema, validateEach, optional, isRequired, isEmail, minLength, minItems, renderApp,
} from "mates";

const guestSchema = schema((g) => ({
name: [isRequired()],
email: [isRequired(), isEmail()],
}));

const orderSchema = schema((order) => ({
buyer: guestSchema,
guests: [minItems(1, "Invite at least one guest"), validateEach(guestSchema)],
note: [optional(), minLength(3)],
}));

const App = () => {
const data = atom({
buyer: { name: "", email: "" },
guests: [{ name: "", email: "" }],
note: "",
});
const out = atom("");

return () => html`
<div class="m-col m-gap">
<strong>Buyer</strong>
<input placeholder="Name" .value=${data().buyer.name}
@input=${(e) => data.update((d) => { d.buyer.name = e.target.value; })} />
<input placeholder="Email" .value=${data().buyer.email}
@input=${(e) => data.update((d) => { d.buyer.email = e.target.value; })} />
<strong>Guests</strong>
${data().guests.map((g, i) => html`
<div class="m-flex m-gap-sm m-items-center">
<input placeholder="Name" .value=${g.name}
@input=${(e) => data.update((d) => { d.guests[i].name = e.target.value; })} />
<input placeholder="Email" .value=${g.email}

Conditional — validateOnlyIf

predicate receives { value, parent, root, path }. Maiden name required only when gender is female.

import { html, atom, schema, validateOnlyIf, isRequired, minLength, isOneOf, renderApp } from "mates";

const profileSchema = schema((p) => ({
gender: [isRequired(), isOneOf(["male", "female", "other"])],
maidenName: validateOnlyIf(
(ctx) => ctx.parent.gender === "female",
[isRequired(), minLength(2)],
),
}));

const App = () => {
const data = atom({ gender: "male", maidenName: "" });
const out = atom("");

return () => html`
<div class="m-col m-gap">
<label class="m-col m-gap-xs">
Gender
<select .value=${data().gender}
@change=${(e) => data.update((d) => { d.gender = e.target.value; })}>
<option value="male">male</option>
<option value="female">female</option>
<option value="other">other</option>
</select>
</label>
<label class="m-col m-gap-xs">
Maiden name (required only when female)
<input .value=${data().maidenName}
@input=${(e) => data.update((d) => { d.maidenName = e.target.value; })} />
</label>
<button class="btn-primary" @click=${async () =>
out.set(JSON.stringify(await profileSchema(data()), null, 2))}>
Validate
</button>
${out() ? html`<pre class="muted m-t-5">${out()}</pre>` : ""}
</div>

validateCustom + validateTuple

Ad-hoc cross-field errors; positional schemas for fixed-length arrays.

import {
html, atom, schema, validateObject, validateCustom, validateTuple,
isArray, isRequired, isMin, isString, isNumber, renderApp,
} from "mates";

const App = () => {
const range = atom({ start: 10, end: 3 });
const pair = atom(["", 3]);
const customOut = atom("");
const tupleOut = atom("");

return () => html`
<div class="m-col m-gap">
<div class="m-flex m-gap-sm m-items-center">
<label>Start
<input type="number" .value=${range().start}
@input=${(e) => range.update((d) => { d.start = +e.target.value; })} />
</label>
<label>End
<input type="number" .value=${range().end}
@input=${(e) => range.update((d) => { d.end = +e.target.value; })} />
</label>
</div>
<button @click=${() => {
const r = validateCustom((errors) => {
if (range().end <= range().start) errors.range = "End must be after start";
});
customOut.set(JSON.stringify(r, null, 2));
}}>validateCustom</button>
${customOut() ? html`<pre class="muted m-t-5">${customOut()}</pre>` : ""}

<p class="muted m-t-5">validateTuple — positional schemas</p>
<input .value=${pair()[0]}
@input=${(e) => pair.update((p) => { p[0] = e.target.value; })} />
<input type="number" .value=${pair()[1]}
@input=${(e) => pair.update((p) => { p[1] = +e.target.value; })} />