Validation

Reepolee uses Zod for validation. The library is vendored at vendor/zod.min.js, so it is part of your codebase. Each generated route folder has one schema in schema/validation_server.ts. It validates submitted form values and supplies translated errors to the live validation endpoint.

The same schema runs in two places: on the server when the form is submitted, and on a JSON validate endpoint that FormController calls on every blur. The server check is the source of truth; the live check exists for UX.

The Generated Schema

The generated Books resource uses this schema:

export const schema = z.object({
    id: z.coerce.number().optional(),
    title: z.string().min(1, "title_required"),
    author: z.nullable(z.string().optional()),
    isbn: z.nullable(z.string().optional()),
    published_on: z.nullable(z_date_optional),
    is_in_stock: z.nullable(z.coerce.number().min(0, "is_in_stock_required").optional()),
});

There is no generated index_schema or form_schema. Database rows keep the types returned by Bun's SQL driver. Templates format values for HTML controls explicitly. For example, the Books form uses js_date_to_iso_string(record.published_on) for its date input.

Running Validation

validation_server.ts exports two convenience wrappers around validate_schema():

export const validate = (data, messages) => validate_schema(schema, data, undefined, messages);

export const validate_touched = (data, touched, messages) => validate_schema(schema, data, touched, messages);

Both return a tuple [errors, valid_data]. If validation passes, errors is {} and valid_data is the parsed input. If it fails, errors is keyed by field name and valid_data is null:

import { validate } from "./validation_server";

const [errors, valid_data] = validate(data, ctx.translations.errors);

if (Object.keys(errors).length > 0) {
    return render("users/form", {
        data: { record: data, errors },
        ctx,
    });
}

await create_record(valid_data);
return Response.redirect("/users", 303);

The second argument - ctx.translations.errors - is the translation map for the active locale. validate_schema() looks up each Zod error message string (the translation key) in that object and replaces it with the human-readable text. The key "email_required" becomes the active locale's errors.email_required value from the translations table.

Use valid_data (the parsed, type-coerced object) - not the raw data you started with - when you hand the input on to the database or other services. valid_data has numbers coerced from strings, dates decoded into Date, optional empty strings normalised to null, and any field that isn't in the schema dropped. The shipped auth handlers (login, register, password, profile) all read _valid_data.email, _valid_data.password, etc. on the success path for that reason.

Partial Validation

validate_touched only reports errors for the fields listed in the touched array. This is what the live validation endpoint uses - when a user has just tabbed off the email field, you only want to surface the email error, not flag every other empty required field:

export async function post_books_validate(req: BunRequest): Promise<Response> {
    const ctx = await create_ctx(req, import.meta.dir);
    const body = await req.json();
    const touched: string[] = body.touched || [];
    const [errors] = validate_touched(body, touched, ctx.translations.errors);

    return Response.json({ success: Object.keys(errors).length === 0, errors }, { status: 200 });
}

The response shape - { success: boolean, errors: Record<string, string> } - is what FormController expects. The same endpoint serves the live per-field validation and the all-fields validation that runs on submit.

Client-Side: FormController

FormController is a small vanilla JS class in static/form-controller.js that wires up live validation without any framework. Point it at the form element and its validate endpoint:

<script src="/form-controller.js" defer></script>
<script>
    document.addEventListener("DOMContentLoaded", () => {
        new FormController({
            form: "#entry-form",
            validate_url: "/user/books/validate",
        });
    });
</script>

From that point on, FormController does three things:

  1. Tracks input values - it caches the form's initial values and listens to input events on every named input, textarea, and select.
  2. Validates on blur - when the user tabs off a field (focusout), it posts the current values plus a touched: [field_name] array to validate_url and writes the response's errors[field_name] into the <validation-error> element with id error-{field_name}.
  3. Validates on submit - when the form is submitted, it preventDefaults, posts all fields to validate_url, and only allows the native form submission to fire if the server returns success: true. If there are errors, they're rendered inline and the POST to the server never happens.

The validation-error Element

Framework edit form showing required-field validation messages below the Author and Is Javascript controls

Validation errors render inside a <validation-error> custom element. The element is a thin shadow-DOM wrapper that displays its slotted content and re-renders when the content changes:

<input type="text" id="title" name="title" value="{= record.title }" />
<validation-error id="error-title"></validation-error>

Two things happen here:

  • On a failed form POST, the server renders the form with its validation errors.
  • On live validation, FormController finds #error-title and replaces its content with the returned error string.

The element renders nothing when its content is empty, so an unused <validation-error> is invisible.

The convention id="error-{field_name}" is what makes the wiring automatic - there is no registration step beyond matching the id. All of the shipped input components follow this convention.

Error Messages and Translations

Validation error messages live in the translations table under the errors key path:

INSERT INTO translations (locale, namespace, key_path, translation) VALUES
    ('en-us', 'books', 'errors.title_required', 'Title is required'),
    ('en-us', 'books', 'errors.is_in_stock_required', 'Select whether this book is in stock');

Your Zod schema references these keys as the message string:

title: z.string().min(1, "title_required"),

At validation time, validate_schema() swaps each key for the translated string. Add a row for a new key to the translations table and reference it in the schema. The full localisation story is in Translations.

Dates and Form Values

The generated schema validates the strings posted by HTML date controls with helpers from $lib/validation_helpers:

HelperUse case
z_date_requiredRequired date input
z_date_optionalOptional date input
z_datetime_requiredRequired datetime-local input
z_datetime_optionalOptional datetime-local input
import { z_date_optional } from "$lib/validation_helpers";

export const schema = z.object({
    published_on: z.nullable(z_date_optional),
});

Formatting an existing database value for an input is a template concern. The generated Books form uses js_date_to_iso_string() to produce YYYY-MM-DD.

The generated date.ree field renders the masked <date-input> custom element (see Input Components) rather than a plain <input type="date">. It reads its validation messages from the translations spread as attributes - errors.date_required, errors.invalid_date, errors.date_min, and errors.date_max - all seeded in the root namespace (the seed also ships errors.date_not_future for your own future-date rules). The z_date_* codecs above are still the server-side source of truth; the component just surfaces the same class of errors live in the field.

Custom Rules

The schemas the generator produces cover the common cases. Project-specific rules - "country code must be one of SI, DE, HR", "release date must be in the future", "this user can't be assigned to this team" - go in your route handler after the standard validation runs:

const [errors, valid_data] = validate(data, ctx.translations.errors);

if (valid_data && !["SI", "DE", "HR"].includes(valid_data.country_code)) {
    errors.country_code = ctx.translations.errors.country_invalid;
}

if (Object.keys(errors).length > 0) {
    return render("users/form", { data: { record: data, errors }, ctx });
}

You can also add the check inside the Zod schema with .refine() if it's purely shape-based, but for rules that depend on database lookups (uniqueness, foreign-key existence) the handler is the right place.