# Form Validation A signup form with per-field validation. Format rules run on the browser for live feedback as the user moves through the fields. The server runs the same rules plus availability checks and is authoritative. When the server rejects a submission, it throws a `ValidationError` carrying a per-field map of messages. The browser catches the error, assigns the map into local state, and the template re-renders the inline messages. ## Migration ```bash elements create migration 'add users' -tables=users ``` `app/migrations/-add-users.migration.sql`: ```sql -- add users -- Auto-update updatedAt on row changes. create or replace function touchUpdatedAt() returns trigger language plpgsql as $$ begin new.updatedAt = now(); return new; end; $$; create table users ( id uuid primary key default uuidGenerateV7(), createdAt timestamptz not null default now(), updatedAt timestamptz not null default now(), email text not null unique, handle text not null unique, passwordHash text not null ); create trigger usersTouchUpdatedAt before update on users for each row execute function touchUpdatedAt(); ``` Three columns added on top of the scaffold: `email` (unique), `handle` (unique), and `passwordHash`. The unique constraints are the database's hard guarantee. Even if two signups race past the availability check at the same millisecond, only one row commits. ## Pure validators Each rule returns `string[]`. Multiple errors per field are normal: a password can be too short and also missing a digit. The same functions run on the browser (per-field on blur) and on the server (inside the submit rpc). `app/pages/signup/validators.ts`: ```ts export function validateEmail(email: string): string[] { let msgs: string[] = []; if (!email.trim()) { msgs.push("required"); } else if (!email.includes("@")) { msgs.push("must be a valid email"); } return msgs; } export function validateHandle(handle: string): string[] { let msgs: string[] = []; if (!handle.trim()) { msgs.push("required"); } else if (!/^[a-z0-9_]+$/.test(handle)) { msgs.push("letters, numbers, and underscores only"); } return msgs; } export function validatePassword(password: string): string[] { let msgs: string[] = []; if (password.length < 8) { msgs.push("must be at least 8 characters"); } if (!/[A-Z]/.test(password)) { msgs.push("must include an uppercase letter"); } if (!/[0-9]/.test(password)) { msgs.push("must include a digit"); } return msgs; } ``` ## Page setup ```bash elements create page signup ``` That writes `app/pages/signup/{index.ts, template.html, style.css, test.ts}`. Add a `services.ts` sibling by hand for the shared `SignupForm` interface and all three rpc. The route is a trivial render-the-template handler; the template defines per-field handlers that call the availability rpc on blur and the submit rpc on form submit. `isHandleAvailable` and `isEmailAvailable` are per-field availability checks the browser calls once on blur, after the pure format rule passes. `createAccount` is the submit rpc. It runs every rule server-side and throws `ValidationError` with the structured per-field map if anything failed. `app/pages/signup/services.ts`: ```ts import { sql, session, ValidationError, FieldErrors } from "@elements/app"; import { validateEmail, validateHandle, validatePassword, } from "#app/pages/signup/validators"; export interface SignupForm { email: string; handle: string; password: string; } /** @rpc */ export function isHandleAvailable(handle: string): boolean { return sql(`select 1 from users where handle = ${handle}`).empty(); } /** @rpc */ export function isEmailAvailable(email: string): boolean { return sql(`select 1 from users where email = ${email}`).empty(); } /** @rpc */ export function createAccount(form: SignupForm) { let errors: FieldErrors = {}; let email = validateEmail(form.email); if (email.length === 0 && !isEmailAvailable(form.email)) { email.push("already taken"); } if (email.length > 0) { errors.email = email; } let handle = validateHandle(form.handle); if (handle.length === 0 && !isHandleAvailable(form.handle)) { handle.push("already taken"); } if (handle.length > 0) { errors.handle = handle; } let password = validatePassword(form.password); if (password.length > 0) { errors.password = password; } if (hasErrors(errors)) { throw new ValidationError(errors); } let user = sql<{ id: string }>( `insert into users (email, handle, passwordHash) values (${form.email}, ${form.handle}, crypt(${form.password}, genSalt('bf', 12))) returning id`, ).firstOrThrow(); session.login({ userId: user.id, userName: form.handle }); } function hasErrors(errors: FieldErrors): boolean { for (let v of Object.values(errors)) { if (v && v.length > 0) { return true; } } return false; } ``` `isHandleAvailable` and `isEmailAvailable` are tagged `@rpc` so the browser can call them on blur. `createAccount` calls them as ordinary functions, since it runs on the server in the same module. The server's pass is the source of truth; the browser's per-blur calls are just live feedback. `sql(...).empty()` returns a boolean directly. No count, no `.first()`, no `.length === 0` check at the call site. `app/pages/signup/index.ts`: ```ts import signup from "./template"; export default function route(req, res) { return new signup(); } ``` The route handler is small because every other piece of server-side work lives in `services.ts`. The template imports the pure validators and the rpc from `./services` and defines one handler per field. Each handler combines the pure format check with the availability check. A `submit` handler catches `ValidationError` from the server and reassigns the local error map. `app/pages/signup/template.html`: ```html import "./style.css"; import { session, ValidationError, FieldErrors } from "@elements/app"; import { validateEmail, validateHandle, validatePassword, } from "#app/pages/signup/validators"; import { SignupForm, createAccount, isEmailAvailable, isHandleAvailable, } from "./services"; function emptyForm(): SignupForm { return { email: "", handle: "", password: "" }; } function checkEmail(form: SignupForm, errors: FieldErrors) { let msgs = validateEmail(form.email); if (msgs.length === 0 && !isEmailAvailable(form.email)) { msgs.push("already taken"); } errors.email = msgs; } function checkHandle(form: SignupForm, errors: FieldErrors) { let msgs = validateHandle(form.handle); if (msgs.length === 0 && !isHandleAvailable(form.handle)) { msgs.push("already taken"); } errors.handle = msgs; } function checkPassword(form: SignupForm, errors: FieldErrors) { errors.password = validatePassword(form.password); } function submit(form: SignupForm): FieldErrors { try { createAccount(form); return {}; } catch (err: any) { if (err instanceof ValidationError) { return err.errors; } throw err; } } = {})>

welcome, {session.get('userName')}

errors = submit(form)}>

sign up

checkEmail(form, errors)}>

{msg}

checkHandle(form, errors)}>

{msg}

checkPassword(form, errors)}>

{msg}

``` Each input's `onblur` runs one per-field handler. Pure format rule first; if it passes, the availability `@rpc` runs (one server hit). `errors.email = msgs` writes the field's array. Even an empty `[]` is a valid value because `e:for` of an empty array renders nothing and `errors.email?.length` is falsy. The `class={errors.email?.length && "is-error"}` expression applies the `@elements/style` `is-error` class to the input when at least one message exists. Each message renders as a `

` under the input inside the `

`. The chapter on `@elements/style` defines the visual treatment. On submit, `submit` calls `createAccount(form)`. If the server rejects, `ValidationError.errors` is a `FieldErrors` map; `errors = err.errors` assigns the server's verdict into local state and every failed field shows inline. ## Routes Register the page in `index.ts`: ```ts import signup from "#app/pages/signup"; // ... app.route("/signup", signup); ``` ## Notes - `FieldErrors` is the type alias exported from `@elements/app`. It expands to `Partial>`. The `keyof T` constraint catches field-name typos at compile time, and the array shape supports multiple errors per field without scalar/array branching at the call sites. - `ValidationError` ships with `safe = true` and `statusCode = 422` (Unprocessable Entity, the canonical code for "I parsed your request, but the data failed validation"). The browser receives the structured `errors` map intact across the rpc boundary. - `onblur` per field is the right trigger for format rules and for single-roundtrip availability checks. For per-keystroke availability checks (live username availability as the user types), debounce the rpc the way `elements man recipes search-filter` does. - The pure validators in `app/pages/signup/validators.ts` import cleanly from both the template and the rpc. Add a rule once and both sides pick it up. - The unique constraints on `email` and `handle` mean a race between simultaneous signups still produces a `SqlError` at the database. The availability check is the friendly UX layer; the constraint is the integrity layer.