Manual Recipes Form Validation

Form Validation

elements man recipes/form-validation Read as markdown

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

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 to Partial<Record<keyof T, string[]>>. 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.