How to Handle and Display Form Validation Errors in Air

Air automatically validates Pydantic-backed forms via AirForm.validate(), stores errors in form.errors, and renders accessible HTML with aria-invalid attributes and user-friendly <small> messages when you call form.render().

The Air framework (feldroy/air) provides a tight integration between Pydantic models and HTML form rendering that makes handling and displaying form validation errors straightforward. By wrapping your data models in AirForm classes, you get automatic error detection, user-friendly message translation, and accessible markup injection without manual template logic.

How AirForm Validates Incoming Data

The AirForm class in src/air/forms.py acts as a thin wrapper around a Pydantic BaseModel. When you call form.validate(data), it runs Pydantic validation and stores the results in two key attributes:

  • form.errors: A list of Pydantic ErrorDetails dictionaries containing raw validation failures.
  • form.is_valid: A boolean indicating whether the form passed validation.

According to the source code at lines 331–335 of src/air/forms.py, the validate method receives raw request data, runs model.validate, and populates these attributes. If validation fails, form.errors contains the specific field-level failures you need to display.

Rendering Accessible Error Messages

Air’s default form renderer, default_form_widget (lines 540–606 in src/air/forms.py), automatically translates raw Pydantic errors into accessible HTML. The renderer performs three critical tasks:

  1. Maps technical errors to friendly text via get_user_error_message (lines 396–424), converting types like "int_parsing" into readable phrases such as "Please enter a valid number."
  2. Injects ARIA attributes by adding aria-invalid="true" to any input field that failed validation, ensuring screen readers announce the error state.
  3. Displays inline messages by inserting a <small> element (defined in src/air/tags/models/stock.py at line 2248) with the ID {field_name}-error containing the translated message.

The HTML generation logic looks like this:

(tags.Small(get_user_error_message(error), id_=f"{field_name}-error") if error else "")

This produces markup like <small id="email-error">This field is required.</small> adjacent to the problematic input.

Complete Implementation Examples

Defining a Basic AirForm

Start by creating a Pydantic model and wrapping it in AirForm:

import air
from pydantic import BaseModel

class ContactModel(BaseModel):
    name: str
    email: str | None = None
    message: str

class ContactForm(air.AirForm):
    model = ContactModel

Source: src/air/forms.py (lines 95–106)

Handling POST Requests with Error Display

In your view handler, validate the incoming data and re-render the form if errors exist:

@app.post("/contact")
async def submit(request: air.Request) -> air.Html:
    form = ContactForm()
    data = await request.form()
    form.validate(data)                # ← populates form.errors & form.is_valid

    if form.is_valid:
        return air.Html(air.H1("Thank you!"))

    # Errors → render the same form; values are preserved automatically

    return air.Html(
        air.H1("Please fix the errors below."),
        air.Form(
            form.render(),               # ← includes <small> error messages

            air.Button("Send", type_="submit"),
            method="post",
            action="/contact",
        )
    )

Key lines: validation at src/air/forms.py lines 331–335; rendering with errors at lines 361–371.

Using Dependency Injection

For FastAPI-compatible dependency injection, use from_request:

from fastapi import Depends

@app.post("/contact")
async def submit(contact: air.AirForm = Depends(ContactForm.from_request)):
    if contact.is_valid:
        return air.Html(air.H1(f"Hello {contact.data.name}!"))
    # Errors are already attached to the form instance

    return air.Html(
        air.H1("Fix the errors"),
        air.Form(contact.render(), air.Button("Resend"), method="post")
    )

Source: ContactForm.from_request implementation at src/air/forms.py lines 133–158.

Customizing Error Messages

To override the default user-friendly mappings, monkey-patch get_user_error_message before your forms render:

from air.forms import get_user_error_message as _default_msg

def get_user_error_message(error: dict) -> str:
    # Use the built‑in mapping first

    msg = _default_msg(error)
    # Override a specific case

    if error.get("type") == "int_parsing":
        return "Numbers only, please."
    return msg

Place this customization in a module imported early in your application startup.

Summary

  • AirForm wraps Pydantic models to provide validate() and is_valid attributes for error detection according to src/air/forms.py (lines 21–82).
  • Error rendering is automatic via form.render(), which injects aria-invalid="true" and <small> elements with user-friendly text via default_form_widget (lines 540–606).
  • Error translation happens through get_user_error_message in src/air/forms.py (lines 396–424), mapping technical Pydantic error types to readable strings.
  • Test coverage in tests/test_forms.py (lines 65–88) confirms that invalid forms produce HTML containing both accessibility attributes and correct error messages.
  • Customization is possible by overriding the global get_user_error_message function for project-specific wording.

Frequently Asked Questions

How does Air map Pydantic error types to display messages?

Air uses the get_user_error_message function located at lines 396–424 in src/air/forms.py. This function inspects the type field of each Pydantic ErrorDetails dictionary (e.g., "int_parsing", "missing") and returns a corresponding human-readable string like "Please enter a valid number" or "This field is required."

Can I customize which HTML tag is used for error messages?

The default default_form_widget (lines 540–606) hardcodes the use of the Small tag class from src/air/tags/models/stock.py (line 2248) for inline errors. To use a different tag, you would need to subclass AirForm and override the rendering logic or provide a custom widget function that replaces default_form_widget in your form configuration.

Does Air preserve user input when redisplaying a form with errors?

Yes. When you call form.render() after validation fails, the renderer automatically injects the previously submitted values into each input field’s value attribute. This happens within the default_form_widget logic, ensuring users see their original input alongside the error messages.

How can I test that my form errors render correctly?

The Air test suite demonstrates the expected HTML output in tests/test_forms.py (lines 65–88). You can verify error rendering by creating a form instance, calling validate({}) with empty or invalid data, checking that form.is_valid is False, and asserting that form.render() produces strings containing aria-invalid="true" and your expected error messages within <small> tags.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →