Forms & Validation

Parse POST bodies into typed dataclasses, validate them in one pass, and re-render the form with errors.

Page actions AI-ready formats and sharing
Open LLM text
Share with AI
Ask Claude Ask ChatGPT Ask Gemini Ask Copilot

Overview

Chirp parses a POST body into form data, binds it to a frozen dataclass with type coercion, and validates it in one pass. You declare a dataclass, attach validation rules to its fields withAnnotated, and call form_or_errors(). On bad input it returns aValidationError— a 422 fragment that re-renders your form with per-field messages. On good input it returns your typed instance.

If you come from Flask or Django, this replaces therequest.formdict-juggling and theForm/ModelFormclass with one declarative schema.

The happy path: declare, bind, validate

Declare a frozen dataclass with frozen=True, slots=True, attach rules with Annotated, and let form_or_errors() do binding and validation in the same call.

New in 0.8
from dataclasses import dataclass
from typing import Annotated

from chirp import App, Request, form_or_errors, FormAction, ValidationError
from chirp.validation import required, max_length

app = App()


@dataclass(frozen=True, slots=True)
class TaskForm:
    title: Annotated[str, required, max_length(100)]
    description: str = ""


@app.route("/tasks", methods=["POST"])
async def add_task(request: Request):
    result = await form_or_errors(request, TaskForm, "tasks.html", "task_form")
    if isinstance(result, ValidationError):
        return result  # 422 fragment with per-field errors + raw values

    # result is a TaskForm — title is bound and validated.
    save_task(result.title, result.description)
    return FormAction("/tasks")

A rule in the Annotated metadata is any callable matching (str) -> str | None. Non-callable metadata is ignored, so the same annotation can carry doc strings or sentinels for other tooling. A plain dataclass with noAnnotatedrules runs only binding — behavior is unchanged.

Rule errors and binding (type-coercion) errors merge per field: a badint on one field and a failed rule on another both land inresult.context["errors"], and the raw submitted values come back inresult.context["form"]for re-population. Falsy-but-valid values stay valid —"0" satisfies required, and an omittedlist[str] binds to []without being flagged.

Coerced field types

form_or_errors() (via form_from()) coerces each field to its declared type. Fields without a default are required; a missing required field yields an error.

Annotation Coerced from a form string to
str stripped string
int / float number (ValueError→ field error)
bool True for "true"/"1"/"yes"/"on"
datetime.date / datetime.datetime ISO 8601
decimal.Decimal / uuid.UUID parsed value
enum.Enumsubclass member by value, then by name
list[T] repeated fields (checkbox groups, multi-selects); missing →[]

Extra template context and retargeting

form_or_errors() passes extra keyword arguments straight to the ValidationError template context, andretarget sets an HX-Retargetheader so htmx swaps errors into a different element than the trigger.

result = await form_or_errors(
    request, TaskForm, "board.html", "add_form",
    retarget="#errors",   # optional HX-Retarget header
    columns=COLUMNS,      # extra template context for the re-render
)

Built-in validation rules

Import rules fromchirp.validation. Each returns an error message string on failure orNoneon success.

Rule Field must…
required be present and non-empty ("0"counts as present)
min_length(n) / max_length(n) be at least / at most n characters
email match a basic email structure
url be anhttp/httpsURL
matches(pattern, message=None) match a regex pattern
one_of(*choices) be one of the given choices
integer / number parse as a whole number / any number

Custom rules

A rule is any callable returning an error string orNone. Attach it the same way as a built-in:

def positive(value: str) -> str | None:
    try:
        if float(value) <= 0:
            return "Must be a positive number"
    except ValueError:
        return "Must be a number"
    return None


@dataclass(frozen=True, slots=True)
class PaymentForm:
    amount: Annotated[str, required, positive]

File uploads

Multipart forms expose uploaded files viaform.files. Read the bytes, or stream to disk withsave()(which sanitizes the destination basename against path traversal).

@app.route("/upload", methods=["POST"])
async def upload(request: Request):
    form = await request.form()
    avatar = form.files.get("avatar")  # UploadFile or None
    if avatar is not None:
        await avatar.save(UPLOAD_DIR / avatar.filename)
    return FormAction("/profile")

Form-field macros

Chirp ships Kida macros for labelled fields with automatic error display. They read the sameerrors dict that ValidationError carries, add a field--errorclass to the wrapper, and emit<span class="field-error">plus ARIA attributes (aria-invalid, aria-describedby, aria-required) when a field has messages.

{% from "chirp/forms.html" import text_field, textarea_field %}

<form hx-post="/tasks" hx-target="#task_form" hx-swap="outerHTML">
    {{ text_field("title", form.title ?? "", label="Title",
                  errors=errors, required=true) }}
    {{ textarea_field("description", form.description ?? "",
                      label="Description", errors=errors) }}
    <button type="submit">Create</button>
</form>
Macro Renders
text_field(name, value, label, errors, type, required, placeholder, attrs) Text input — settype for password, email, etc.
textarea_field(name, value, label, errors, rows, required, placeholder) Multi-line text
select_field(name, options, selected, label, errors, required) Dropdown — each option needs.value and .label
checkbox_field(name, checked, label, errors) Checkbox with label
hidden_field(name, value) Hidden input

Gotchas

Advanced & alternate flows

The happy path above covers most forms. Open these when you need the lower-level pieces.