Form Validation
elements man recipes/form-validation Read as markdownA 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
elements create migration 'add users' -tables=users
app/migrations/<timestamp>-add-users.migration.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:
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
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:
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<SignupForm> = {};
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<SignupForm>): 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:
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:
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<SignupForm>) {
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<SignupForm>) {
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<SignupForm>) {
errors.password = validatePassword(form.password);
}
function submit(form: SignupForm): FieldErrors<SignupForm> {
try {
createAccount(form);
return {};
} catch (err: any) {
if (err instanceof ValidationError) {
return err.errors;
}
throw err;
}
}
<html class="signup"
(private form: SignupForm = emptyForm(),
private errors: FieldErrors<SignupForm> = {})>
<div e:if={session.isLoggedIn()}>
<p>welcome, {session.get('userName')}</p>
</div>
<form e:else onsubmit={() => errors = submit(form)}>
<h1>sign up</h1>
<div class="field">
<label>email</label>
<input type="email"
value={form.email}
class={errors.email?.length && "is-error"}
onblur={() => checkEmail(form, errors)}>
<p e:for={msg of errors.email ?? []} class="error">{msg}</p>
</div>
<div class="field">
<label>handle</label>
<input type="text"
value={form.handle}
class={errors.handle?.length && "is-error"}
onblur={() => checkHandle(form, errors)}>
<p e:for={msg of errors.handle ?? []} class="error">{msg}</p>
</div>
<div class="field">
<label>password</label>
<input type="password"
value={form.password}
class={errors.password?.length && "is-error"}
onblur={() => checkPassword(form, errors)}>
<p e:for={msg of errors.password ?? []} class="error">{msg}</p>
</div>
<button type="submit">create account</button>
</form>
</html>
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 <p class="error"> under the input inside the
<div class="field">. The chapter on @elements/style defines the visual
treatment.
On submit, submit calls createAccount(form). If the server rejects,
ValidationError.errors is a FieldErrors<SignupForm> 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:
import signup from "#app/pages/signup";
// ...
app.route("/signup", signup);
Notes
FieldErrors<T>is the type alias exported from@elements/app. It expands toPartial<Record<keyof T, string[]>>. Thekeyof Tconstraint catches field-name typos at compile time, and the array shape supports multiple errors per field without scalar/array branching at the call sites.ValidationErrorships withsafe = trueandstatusCode = 422(Unprocessable Entity, the canonical code for "I parsed your request, but the data failed validation"). The browser receives the structurederrorsmap intact across the rpc boundary.onblurper 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 wayelements man recipes search-filterdoes.- The pure validators in
app/pages/signup/validators.tsimport cleanly from both the template and the rpc. Add a rule once and both sides pick it up. - The unique constraints on
emailandhandlemean a race between simultaneous signups still produces aSqlErrorat the database. The availability check is the friendly UX layer; the constraint is the integrity layer.