Getting started

July 27, 2026 · View on GitHub

This guide builds one complete form, from a Python function to the object the core returns:

Python function
    -> plan_of()
    -> JSON response
    -> fetch()
    -> compileForm()
    -> mount widgets
    -> form.read()
    -> POST
    -> decode()
    -> schema.build()

Only two of those steps belong to pytypehintweb: plan_of() on the Python side and compileForm() / form.read() in the browser. The JSON response, the route, the static files, the fetch and the POST are written by you.

FastAPI is used as the concrete example. Nothing in the library requires it: any framework that can return JSON and read a JSON body works the same way. pytypehintweb provides no server, no routes and no static-file handler of its own.

Installation

pip install pytypehintweb

To run the example below as written:

pip install "pytypehintweb[demo]"

That extra installs FastAPI and Uvicorn, which the bundled demo also uses.

Generate a plan from Python

from typing import Annotated

from pytypehint import Label, Min, signature_of
from pytypehintweb import plan_of


def create_user(
    username: Annotated[str, Min(3), Label("Username")],
    age: Annotated[int, Min(0), Label("Age")],
) -> None:
    """Create a user account."""


schema = signature_of(create_user)
plan = plan_of(schema)

plan_of() accepts the function directly as well; compiling the schema once is useful when the same schema is also needed to build the result later.

It accepts a plain named function, a dataclass type, or an already compiled Signature or Struct — not arbitrary callables. See accepted inputs for what is rejected and why.

The plan is a plain dictionary, fully expanded — every non-conditional property is present with an explicit value (default appears exactly when hasDefault is true):

{
  "v": 1,
  "kind": "form",
  "name": "create_user",
  "description": "Create a user account.",
  "fields": [
    {
      "name": "username",
      "label": "Username",
      "description": null,
      "optional": false,
      "enabled": true,
      "hasDefault": false,
      "node": {
        "kind": "str",
        "options": {
          "minLength": 3,
          "maxLength": null,
          "pattern": null,
          "patternMessage": "Invalid format",
          "minMessage": "Must contain at least {value} characters",
          "maxMessage": "Must contain at most {value} characters",
          "placeholder": null,
          "password": false,
          "rows": null,
          "choices": null
        }
      }
    },
    {
      "name": "age",
      "label": "Age",
      "description": null,
      "optional": false,
      "enabled": true,
      "hasDefault": false,
      "node": {
        "kind": "int",
        "options": {
          "min": 0,
          "max": null,
          "multipleOf": null,
          "step": null,
          "slider": false,
          "showValue": false,
          "placeholder": null,
          "choices": null,
          "safeMessage": "Must be a safe integer",
          "invalidMessage": "Enter a valid integer",
          "minMessage": "Must be at least {value}",
          "maxMessage": "Must be at most {value}",
          "multipleOfMessage": "Must be a multiple of {value}",
          "increaseLabel": "Increase",
          "decreaseLabel": "Decrease"
        }
      }
    }
  ]
}

There is a single representation now, and the browser never reconstructs an absence. See the plan contract for every property and its meaning.

Serve the browser files

The browser modules ship inside the Python package. pytypehintweb.STATIC is the directory holding form.js, its sibling modules, widgets.css and the icons/ subdirectory the stylesheet references. Serve the directory whole — StaticFiles does, and so does copying it — because widgets.css addresses its icons relative to itself, so they work under any prefix but must be there. The library exposes the path; serving it is up to the application:

from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles

from pytypehintweb import STATIC, decode

app = FastAPI()
app.mount("/static", StaticFiles(directory=STATIC), name="static")


@app.get("/form-plan")
def form_plan():
    return plan


@app.post("/users")
def create(data: dict):
    return repr(schema.build(decode(schema, data)))

decode() prepares the JSON-parsed body for schema.build(): it is the reverse-pipeline counterpart of plan_of(), coercing the values the transport cannot express by exact type (today, an int where the schema wants a float) and leaving everything else untouched. This form has none, so decode() returns the body unchanged; adding it now keeps the backend correct as the schema grows.

Copying the files next to your own assets works just as well; they are plain ES modules with no build step.

Fetch and compile the plan

<link rel="stylesheet" href="/static/widgets.css">

<div id="form" class="pth-root"></div>
<button id="submit" type="button">Create</button>
<pre id="answer"></pre>

<script type="module">
  import { compileForm } from "/static/form.js";

  const response = await fetch("/form-plan");
  const plan = await response.json();

  const form = compileForm(plan);
</script>

compileForm() receives an already parsed object. It never fetches and never parses JSON: obtaining the plan belongs to the application.

Mount the fields

  const host = document.getElementById("form");

  for (const field of form.fields) {
      host.append(field.widget.el);
  }

Mounting is explicit. The library builds the elements and hands them over.

The container carries class="pth-root" because the stylesheet themes from it: inside a root the widgets follow the system's light or dark preference, and data-pth-theme="light" or "dark" on the root — or on any ancestor — forces one. The theme is presentation only; it never reaches the plan or the values. See Styling.

Read and submit values

  const submit = document.getElementById("submit");
  const answer = document.getElementById("answer");

  const refresh = () => {
      submit.disabled = !form.isReady();
  };

  const unsubscribe = form.onChange(refresh);

  refresh();

  submit.addEventListener("click", async () => {
      const result = await fetch("/users", {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify(form.read()),
      });

      answer.textContent = await result.text();
  });

form.read() returns the transport object described by the plan. The core validates it and builds the result:

schema.build({"username": "ada", "age": 36})
# {'username': 'ada', 'age': 36}

The browser anticipates errors so the form is pleasant to use, but the authoritative validation stays in pytypehint.

Use a manual plan

Python is optional at render time: compileForm() consumes any fully expanded plan, whether it came from plan_of(), a hand-written constant, or another backend — the compile-and-mount steps above are identical. Authoring one by hand is covered in the JavaScript API; plan_of() remains the reliable way to produce the full document.

Run the demo

pytypehintweb-demo

The demo shows the same pipeline for a catalogue of examples: the model source, the plan, the mounted form, its live state, the transport object and the core's answer.

Next steps