# Python API

The Python side of `pytypehintweb` is an adapter. It reads a compiled
`pytypehint` schema and produces a fully expanded plan. It renders nothing and
it validates no user input.

See the [plan contract](https://offerrall.github.io/doc/pytypehintweb/plan.md) for the shape of the produced document, the
[architecture](https://offerrall.github.io/doc/pytypehintweb/architecture.md) for where this layer sits, and
[getting started](https://offerrall.github.io/doc/pytypehintweb/getting-started.md) for a complete end-to-end example.

The final validation and the construction of the resulting objects stay in
`pytypehint`: an application that receives `form.read()` passes it to
`Signature.build()` or `Struct.build()`.

## Public API

Everything the package exports from `pytypehintweb`:

| Name | Kind | Purpose |
| --- | --- | --- |
| `plan_of` | function | compiles a schema into a fully expanded plan |
| `decode` | function | prepares a JSON-parsed transport object for `schema.build()` |
| `WebConfig` | dataclass | the configurable texts of the web layer |
| `STATIC` | `Path` | the directory holding the browser runtime files |
| `PLAIN`, `INLINE`, `WRAPPED` | `str` | the three union transport modes |
| `__version__` | `str` | the installed version |

Anything not listed here is an implementation detail and may change without
notice.

## `plan_of()`

```python
plan_of(obj, *, config: WebConfig | None = None) -> dict
```

Returns a plain dictionary of plain data: dictionaries, lists, strings,
integers, booleans and `None`. It is not a JSON string, and nothing in it
references core classes.

`config` must be a `WebConfig` instance or `None`. The check is
`type(config) is WebConfig`: no subclasses, no coercion. `False`, `0`, `""`
and `{}` are errors, not shorthands for the factory configuration.

## Accepted inputs

`plan_of()` accepts:

- a plain named function accepted by `pytypehint.signature_of()`;
- a dataclass type accepted by `pytypehint.struct_of()`;
- an already compiled `Signature`;
- an already compiled `Struct`.

| Input | Result |
| --- | --- |
| plain named function | compiled with `signature_of()`; the form name is the function name and the docstring becomes the form description |
| dataclass type | compiled with `struct_of()`; the form name is the class name and the description is absent |
| compiled `Signature` | used directly |
| compiled `Struct` | used directly |

It does not accept arbitrary callables. Lambdas, bound methods,
`functools.partial` objects and objects implementing `__call__` are rejected.
Wrap such behaviour in a plain named function before compiling it.

Passing an already compiled schema avoids recompiling the original function or
dataclass, which is useful when the same schema is needed twice — once to
build the plan and once to build the submitted values:

```python
from pytypehint import signature_of, struct_of
from pytypehintweb import plan_of

schema = signature_of(create_user)

plan = plan_of(schema)
result = schema.build(transport)
```

Both forms produce the same document:

```python
plan_of(create_user) == plan_of(signature_of(create_user))
plan_of(User) == plan_of(struct_of(User))
```

### Rejected inputs

A dataclass *instance* is rejected, because the plan describes a type, not a
value:

```python
plan_of(User(name="ada"))
# TypeError: expected a dataclass type, got an instance of User
```

These are rejected too. The error usually comes from the core, which explains
the specific problem:

| Input | Why |
| --- | --- |
| `lambda value: value` | lambdas have no usable name |
| `functools.partial(fn, x=1)` | not a plain function |
| `instance.method` | bound methods are not plain functions |
| `Class.classmethod` | bound to the class, not a plain function |
| `Class.method` | the leading `self` parameter is not a form field |
| an object with `__call__` | not a plain function |
| an ordinary class | not a plain function |
| anything else | not a supported category |

A `@staticmethod` accessed through its class *is* a plain function, so it is
accepted.

Anything the adapter cannot classify raises:

```text
TypeError: expected a plain function, a dataclass type, a Signature or a
Struct, got ...
```

### Function restrictions

Function inputs inherit the restrictions of `pytypehint.signature_of()`. Each
parameter becomes a form field, so a parameter that cannot be described as one
is refused:

| Signature | Result |
| --- | --- |
| `def f(value: str)` | accepted |
| `def f(value: str = "x")` | accepted; the default initialises the field |
| `def f(*, value: str)` | accepted; keyword-only is fine |
| `def f(value)` | rejected: `missing type hint` |
| `def f(value: str, /)` | rejected: positional-only parameters are not supported |
| `def f(*args: str)` | rejected: variadic parameters are not supported |
| `def f(**kwargs: str)` | rejected: variadic parameters are not supported |
| `def f(self, value: str)` | rejected: looks like an unbound method |

To build a form for something that is not a plain function, write one:

```python
service = Service()


def run(query: str) -> Result:
    return service.search(query)


plan = plan_of(run)
```

`plan_of()` reads the signature of `run`; it never calls it. Executing the
function is the host application's job.

## Strings

A `Str` shape maps to a `str` node. The metadata that reaches the plan:

| Annotation | Plan option |
| --- | --- |
| `Min` | `minLength` |
| `Max` | `maxLength` |
| `Pattern` | `pattern`, plus `patternMessage` when the annotation carries a message |
| `Placeholder` | `placeholder` |
| `IsPassword` | `password` |
| `Rows` | `rows` |
| `Choices` | `choices` |
| `Literal[str]` | `choices` |
| `Label` | the field `label` |
| `Description` | the field `description` |

`minLength` and `maxLength` count Unicode code points, matching Python's
`len(str)`. The browser measures the same way.

`Rows`, `IsPassword` and `Choices` ask for three different controls. Any two
of them together raise `TypeError` rather than letting the adapter pick one.
`Placeholder` is likewise rejected next to `Choices`: a closed select has no
empty prompt, so there is nothing for the placeholder to fill.

A `Choices` field always represents one of its values. It opens on the explicit
default when there is one, otherwise on the first choice, so it is ready from
the first render with no "choose one" step.

### Files

`FileHint` on a `str` turns the field into a **file node** (`kind: "file"`)
rather than a text box. Its value is a reference string the browser widget
**generates locally** the moment the user picks a file — the file's name
compressed to bare ASCII (15 characters at most), a UUID, and the file's
lowercased extension. The browser never interprets it beyond the **extension**:
a lenient `value.lower().endswith(ext)` filter over the declared list — a guard
against honest mistakes. Nothing about the transport changes: a file field is a
`str` on the wire.

**A reference is not a path, and it carries no bytes.** It is an opaque token
that means something to the host and to nobody else. `FileHint` reads one thing
off it, the extension, and reads it off the *text*: the core opens nothing, so it
cannot tell you whether the reference stands for stored bytes, whether they are
still there, how many there are or whose they are. Answering that is the host's
job, and the seam the library gives it is `decode(..., file_resolver=...)`:

```python
prepared = decode(schema, body, file_resolver=lambda ref: str(UPLOADS / ref))
resolved = schema.build(prepared)
```

The resolver is where a host looks the reference up in its own storage and
decides what to hand the pipeline — a path, an object-store key, anything the
function behind the schema understands. It is also where a host refuses: raise
from it and the exception travels out of `decode()` unchanged. Without a resolver
the reference travels untouched and `build()` returns it as the plain string it
is. See [`file_resolver`](https://offerrall.github.io/doc/pytypehintweb/python.md#file_resolver) for the full walk it covers.

| Annotation | Plan option |
| --- | --- |
| `FileHint(extensions=...)` | `extensions` — lowercase, dotted, possibly empty (any file), mapped to the input's `accept` |
| `FileHint(min_size=...)` / `max_size=...` | `minSize` / `maxSize` — bytes, per file, `null` when unbounded |
| `list[Annotated[str, FileHint(...)]]` | one file node with `multiple: true`; the list's `Min`/`Max` become `minFiles`/`maxFiles` |

The byte bounds are **per file**, never a combined total: three 4 MB files under
a 5 MB `max_size` are three valid files. Counting them is `minFiles` / `maxFiles`,
a separate question.

They are a **declaration**, and the browser is the only place that can act on
it. A local `File` carries a `.size`, so the widget refuses one that already
breaks a bound before any upload happens — failing before the bytes move. A
reference carries no bytes at all, so nothing weighs it: not the widget, which
has no size to read, and not the core, which never opens anything. **A byte
bound you need to be authoritative belongs beside the storage that holds the
bytes**, in the host's own upload endpoint or resolver. The only bound that has
to be a safe JavaScript integer is the one written into the plan; `plan_of()`
refuses a larger one rather than rounding it.

**A file composes like any other node.** There is no rule about files in lists,
in structs or in unions: a shape is representable when each of its nodes is, and
the compiler recurses. Writing `File` where you would write `str` works at any
depth, with or without a default:

| Shape | Node |
| --- | --- |
| `Annotated[str, FileHint()]` | `file` |
| `Annotated[str, FileHint()] \| None` | `file`, optional |
| `list[Annotated[str, FileHint()]]` | one `file` node with `multiple: true` |
| `list[Annotated[str, FileHint()] \| None]` | `list` of `optional` of `file` |
| `list[Annotated[str, FileHint()] \| int]` | `list` of `choice` |
| `list[list[Annotated[str, FileHint()]]]` | `list` of `multiple` `file` |
| a dataclass with a file field | `object` containing a `file` |
| a dataclass with a `list[File]` field | `object` containing a `multiple` `file` |
| `list[SomeDataclassWithAFile]` | `list` of `object` |
| a file as one branch of a union whose other branches are not strings | `choice` |

**`list[File]` has one special case, and it is a shortcut, not a restriction.**
A *bare* `list[File]` becomes a single `multiple` file node — one input that
takes several files at once, minting one reference each, with the list's
`Min`/`Max` as file-count bounds — because that is what a user expects from it.
Any other list compiles its item recursively like any other list. This is also
why `list[list[File]]` is a `list` whose rows are `multiple` file nodes: the
shortcut applies at the inner level, and the outer list stays a list.

### File defaults and prefills

**A file default is an existing reference.** A single file takes a `str`, a
`list[File]` takes a `list[str]`, and an optional file takes `None` for its off
state. In the browser it is exactly what `FileWidget.setValue()` means — shown as
the current file, transported back untouched, carrying no bytes, no local `File`
and no upload — because `compileForm()` applies it *by calling* `setValue()`.

There are two ways a reference can reach a form, and they are the same road seen
from two points:

**1. A default in the Python schema, including a prefill.** A prefill is a
temporary default, so it takes the same route:

```text
value → schema default → pytypehint checks the extension
      → plan_of() → plan → compileForm() → setValue()
```

The core checks what it can check about a `str`: that it is one, and that its
text ends in an accepted extension. A `list[File]` default is checked element by
element — `[1]: not an accepted file type` names the offender — and the list's
`Min`/`Max` are checked too. Nothing else is inspected: the value is not opened,
so a reference that names no local file is a perfectly good default, and so is an
object-store key. That is deliberate — most hosts do not keep uploads as local
paths, and a reference is exactly the token they do have.

**2. A reference applied at runtime with `setValue()`.**

```text
widget.setValue("bucket/key.pdf")        → frontend state
form.read() → decode(..., file_resolver=…) → the host's own value → schema.build()
```

Both roads land the widget in the same observable state, and neither of them
proves the bytes exist, and nothing in this library or in the core claims to:
the core opens no files. A reference that has expired,
that was never uploaded or that belongs to somebody else shows fine in the form
and travels back intact. **The place to catch it is the resolver**, which is the
one point where code that knows the storage sees the reference:

```python
def resolve(reference: str) -> str:
    record = uploads.get(reference, owner=current_user)   # the host's own rules

    if record is None:
        raise LookupError(f"unknown or expired upload: {reference}")

    return record.path
```

`decode()` does not catch that exception; it propagates unchanged, with its own
type, to whoever called `decode()`.

**A struct with an internal path round-trips through an edit form.** Create and
edit are the same form: a host builds it from a `Struct` (`struct_of(User)`),
`setValue()`s each field from an existing record — including the file field, whose
string is an *existing* reference shown as the current file — the user edits what
they edit, and `schema.build(decode(schema, form.read(), file_resolver=...))`
returns the whole `User`. An untouched avatar comes back as the same reference it
went in as, byte-identical, and it is that reference the resolver is handed; a
replaced one comes back as the fresh reference the new local choice minted; no
bytes move for the files left alone. A plan default is the same thing declared
one step earlier: `setValue()` plants the current file at mount, a default has
the plan carry it, and the widget cannot tell the two apart because the default
reaches it *through* `setValue()`.

**Storage is the host's, entirely.** The library mints and transports a
reference and knows nothing about where bytes live — not where they are, not
whether they arrived, not how many there are. A host that mints a reference and
never uploads the bytes ships a string that points at nothing, and the only code
in a position to notice is its own: at the upload endpoint, or in the
`file_resolver` when the reference comes back. One caveat worth knowing: the
resolver's **answer** is what continues down the pipeline, so it faces the
extension check too. A host resolving `report.pdf` to a bare key
`s3://bucket/9f3a1c` fails at `build()` with `not an accepted file type` — keep
the extension, or declare `FileHint()` without one. func-to-web (or any wrapper)
owns that mapping; see
[Values completed outside the browser](https://offerrall.github.io/doc/pytypehintweb/javascript.md#values-completed-outside-the-browser).

Only a bare `FileHint` is emitted. The other `Str` atoms — `Min`, `Max`,
`Pattern`, `Choices`, `IsPassword`, `Rows`, `Placeholder` — describe a text box
and have no meaning on a file control, so any of them alongside `FileHint`
raises `TypeError` ("not supported yet"), exactly as `Float.slider` does. `FileHint`'s own `min_size` and `max_size` are
**not** among them: they describe the file, not a text box, and they travel.
`Label` and `Description` are the field's, not the `Str`'s, so a labelled file
field is fine.

### Patterns

Pattern support is intentionally conservative. The core validates with
Python's `re` and the browser runs `RegExp`; the two are different engines,
so only the subset that behaves identically in both is accepted. A pattern
that merely *compiles* in JavaScript is not enough.

The browser wraps the pattern as `` new RegExp(`^(?:${pattern})$`, "u") ``,
which reproduces `re.fullmatch()`. Plans never carry flags.

Patterns outside the supported subset are rejected during plan generation,
not at render time. The rejected categories are listed in
[the plan contract](https://offerrall.github.io/doc/pytypehintweb/plan.md#pattern-portability).

### Types

`pytypehintweb` exports two convenience aliases, importable from the top level:

```python
from pytypehintweb import Color, Email, COLOR_PATTERN, EMAIL_PATTERN
```

They are **not new types**. Each is an `Annotated[str, Pattern(...)]`, so the plan
they produce is an ordinary `str` node — the same node the equivalent hand-written
annotation produces — and nothing in the contract, transport or `decode()` knows
they exist. Each alias does set the pattern's `message`, so the node's
`patternMessage` carries that wording rather than the generic default; it is not
identical to a bare `Pattern(...)` with no message. They exist only to save every
caller from repeating the pattern.

- `Color` is `Annotated[str, Pattern(COLOR_PATTERN, message="Hex color like
  #ff5733")]`, where `COLOR_PATTERN` is `#[0-9a-fA-F]{6}` — a hex colour like
  `#ff5733`. This exact string is also the
  opt-in for the browser's [colour assistant](https://offerrall.github.io/doc/pytypehintweb/javascript.md#direct-widget-usage):
  a `StrWidget` whose pattern equals it mounts a picker beside the text field.
- `Email` is `Annotated[str, Pattern(EMAIL_PATTERN, message=...)]`, where
  `EMAIL_PATTERN` is
  `[^@ ]+@[^@ ]+\.[a-z]{2,}` — a run of non-space, non-`@` characters, an `@`,
  another such run, a dot and a suffix of two or more lowercase letters. It is a
  **format filter, not an email validator**: it rejects plenty of RFC-valid
  addresses and accepts plenty of nonsense, exactly the humility
  [limitations](https://offerrall.github.io/doc/pytypehintweb/limitations.md#regular-expressions) records for the pattern
  subset. `Email` carries no `Placeholder`, so it composes cleanly with a
  caller's own metadata.

Both compose the usual way — typing flattens the nested `Annotated`, so
`Annotated[Color, Label("Fondo")]` reaches the core as the pattern on the node and
the label on the field.

## Integers

Every integer that reaches a plan is a JavaScript safe integer: bounds,
`multipleOf`, `step`, choices, list lengths and defaults at any depth go
through the same check. If the core accepts a larger one, `plan_of()` fails
and says where. This does not restrict `pytypehint` itself; it only prevents
sending a silently rounded value to the browser.

| Annotation | Plan option |
| --- | --- |
| `Min` | `min` |
| `Max` | `max` |
| `MultipleOf` | `multipleOf` |
| `Step` | `step` |
| `Slider` | `slider`, plus `showValue` |
| `Choices` | `choices` |
| `Literal[int]` | `choices` |

Exclusive bounds are converted to inclusive browser limits: an exclusive
minimum becomes `value + 1` and an exclusive maximum becomes `value - 1`.
When that conversion leaves the safe range, the error reports the original
exclusive bound and the inclusive value it would have needed.

`Step` controls presentation, `MultipleOf` controls validity. They can
coexist: `Step(25)` moves the arrows in steps of 25 but still lets someone
type 37, and `MultipleOf(25)` is what makes that 37 invalid. On an ordinary
number input the `▲`/`▼` arrows step by `step`, else by `multipleOf`, else by
`1`; that is a widget-local increment only — `MultipleOf` is never copied into
the plan's `step`.

`Slider` is another presentation of the same integer and needs both bounds.
`Choices` and `Slider` together are rejected.

For a slider, `Step` decides where the control can land and `MultipleOf`
decides whether the represented integer is valid; `MultipleOf` is not copied
into the slider stride. Usually

```python
Annotated[int, Slider(), Step(5), MultipleOf(5)]
```

is what you want: the slider only stops on multiples of 5. Without the matching
`Step`,

```python
Annotated[int, Slider(), MultipleOf(5)]
```

leaves the default stride of 1, so the slider can visit intermediate positions
that are not multiples of 5 and therefore invalid. The adapter still requires
at least one reachable valid position, but it will not silently align the two
for you.

## Floats

A `Float` shape maps to a `float` node. The metadata that reaches the plan:

| Annotation | Plan option |
| --- | --- |
| `Min` | `min`, plus `minExclusive` |
| `Max` | `max`, plus `maxExclusive` |
| `Step` | `step` |
| `Choices` | `choices` |
| `Placeholder` | `placeholder` |
| `Label` | the field `label` |
| `Description` | the field `description` |

Unlike an integer, a float bound is **not** converted. The core compares a
float directly against its bound and an exclusivity flag (`value <= min` when
exclusive, `value < min` when not), so the adapter emits the bound value
verbatim and carries `minExclusive` / `maxExclusive` beside it. There is no ±1
neighbour, because a float has no next representable step. An empty range —
`min > max`, or `min == max` with either side exclusive — is rejected by the
core when it compiles the schema, so `plan_of()` never re-checks it.

Every finite double is representable in the browser, so there is no "safe float"
check the way there is for integers: `min`, `max`, `choices` and defaults travel
as they are. `Step` is presentation only — the stride of the `▲`/`▼` arrows —
never a validation grid.

`Slider` on a `Float` is **rejected** with `TypeError: Float.slider is not
supported yet`: the `min + k*step` grid a slider needs has no exact float
arithmetic, so a legitimate default could be refused or the control would have
to silently correct a value, and the doctrine forbids both (see
[limitations](https://offerrall.github.io/doc/pytypehintweb/limitations.md#float-slider)). `Choices` and `Placeholder`
together are rejected, as for the other scalars: a closed select has no empty
prompt.

The float stepper reuses the integer stepper's accessible labels
(`int_increase_label` / `int_decrease_label`): the arrows do the same job, so
their names do not vary by type. The float-specific texts —
`float_invalid_message`, `float_finite_message`, `float_min_message`,
`float_max_message` — are on [`WebConfig`](https://offerrall.github.io/doc/pytypehintweb/python.md#webconfig).

## Dates and times

A `Date` maps to a `date` node and a `Time` to a `time` node, each a native
picker. Both travel as ISO text — a date as `YYYY-MM-DD`, a time as `HH:MM:SS`
(the native control shows seconds) — so the metadata that reaches the plan is
the same for both:

| Annotation | Plan option |
| --- | --- |
| `Min` | `min` (date: plus the ±1-day conversion; time: plus `minExclusive`) |
| `Max` | `max` (date: plus the ±1-day conversion; time: plus `maxExclusive`) |
| `Choices` | `choices` |
| `Placeholder` | `placeholder` |
| `Label` | the field `label` |
| `Description` | the field `description` |

Bounds, choices and defaults are emitted in the **canonical** ISO form
`isoformat()` produces, which is fixed-width, so the browser compares them
lexicographically. A time is always `HH:MM:SS`: the core pins it to whole seconds
and rejects any microsecond, so no sub-second fraction is ever emitted. A date is
always a real calendar date as `YYYY-MM-DD` — it starts as a `datetime.date`, so
an impossible date cannot exist here; the browser enforces the same rule for
hand-written plans and direct widget use.

The one asymmetry is deliberate, and each type copies its own core: **a date
bound is converted like an integer — an exclusive bound becomes the neighbouring
inclusive one by ±1 day, so the node carries no flag — while a time bound is
kept like a float — the core compares it directly, so the node carries
`minExclusive`/`maxExclusive` and no conversion happens.** Neither has a slider,
step or `multiple_of`; `Choices` with `Placeholder` is rejected, as for the
other scalars. The date/time texts — `date_invalid_message`, `date_min_message`,
`date_max_message`, `time_invalid_message`, `time_min_message`,
`time_max_message` — are on [`WebConfig`](https://offerrall.github.io/doc/pytypehintweb/python.md#webconfig).

## Booleans

A `bool` maps to a `bool` node, rendered as a native checkbox. It has no
metadata of its own — no bounds, choices, placeholder or slider — so the node's
`options` is an empty object. `Label` and `Description` still apply, as on any
field.

A checkbox always represents a value: unchecked is `false`, checked is `true`.
A `bool` default of `True` or `False` travels certified by the core; `bool |
None` makes the field optional (the toggle expresses `None`, the checkbox never
does). In a union, a `bool` branch carries `option_id()` `"bool"` and travels
`plain` (`true`/`false`), since it never collides with another `bool`.

## Enums

An `EnumShape` maps to an `enum` node, rendered as a select over its members'
names. An enum carries no constraint metadata — the closed set of members *is*
the constraint — so the node's `options` is just `choices` (the member names, in
declaration order), with `placeholder` and `labels` always `null`. `Label` and
`Description` still apply, as on any field. `Flag` enums and empty enums are
rejected by the core, so an `enum` node always has at least one choice.

A member travels as its **name** (`.name`), never its value: a value can be
anything, repeat across aliases, or not serialize, while a name is a stable JSON
string. An enum default travels as the member name, certified by the core, and
`decode()` restores the exact member — the lookup is the core's, on the enum
class the shape carries.

**Aliases.** When two members share a value (`A = 1; B = 1`), `B` is an alias of
`A`: `list(cls)` yields only canonical members, so an alias name never appears in
`choices`. If an external producer sends an alias name, the lookup reads through
`__members__` and answers with the canonical member, so `decode()` returns the
member the alias points at and `build()` accepts it (its type is the enum class).

`Extra` on the core's enum is stored but **not interpreted** by the adapter: the
widget shows the member names, and the node's `labels` slot is always `null`
(see [limitations](https://offerrall.github.io/doc/pytypehintweb/limitations.md#unsupported-metadata)).

In a union, a member is a JSON string, so an enum shares the string transport
with `str`, `date`, `time` and other enums: every such branch travels `wrapped`,
with the enum's class name (`option_id()`) as its `$type`.

## Lists

List constraints and item constraints are independent: `Min` on the list is
`minItems`, `Min` on the item is the item's own constraint.

- A list with no default starts empty, even when it has a minimum.
- `minItems` does not create rows. The interface never invents values to
  satisfy a constraint; it only reports that the list is not ready and
  refuses to remove below the limit.
- `maxItems` prevents adding above the limit.
- A default materializes exactly its elements and no others.
- Nested lists are supported.
- Union items are supported: `list[A | B]` produces one choice per row.

`list[A] | list[B]` is a different question: the choice covers the whole
list, so rows cannot mix types.

## Optional values

A union containing `None` makes the field optional. Disabling the toggle
means `None`.

The initial toggle state is resolved in Python and travels in the plan:

1. `OptionalToggle` wins when present;
2. otherwise a missing default leaves the field enabled;
3. otherwise the field is enabled when the default is not `None`.

Turning a field off hides its widget without destroying it. Whatever was
typed is still there when it is turned back on.

Inside a list, an optional item becomes an explicit `optional` node wrapping
the item node. An optional field carries its own `optional` flag instead.

## Unions

A union with two or more non-`None` branches becomes a `choice` node. A
single branch is emitted as its own node, with no choice around it.

Each branch carries `Shape.option_id()` from the core as its `value`, and the
adapter assigns each a transport `mode` — `plain`, `inline` or `wrapped` — by
the rules in the [plan contract](https://offerrall.github.io/doc/pytypehintweb/plan.md#union-transport). The core compiler
rejects the homonym enums or dataclasses a union's normal path could produce;
`plan_of()` still fails if two branches reach it sharing an option id — defense in
depth, because nothing downstream could tell them apart.

The three mode names are also exported as constants, so that code inspecting
a generated plan does not have to repeat string literals:

```python
from pytypehintweb import PLAIN, plan_of

plan = plan_of(order)
branch = plan["fields"][0]["node"]["branches"][0]

if branch["mode"] == PLAIN:
    ...
```

They are plain strings — `"plain"`, `"inline"` and `"wrapped"` — identical to
the values that appear in the plan, so comparing with `==` is correct and a
plan that has crossed JSON compares the same way.

## Defaults

Defaults come from the compiled schema, not from re-reading the original
dataclass, and they initialize the entire tree: scalars, optionals, lists,
choices and nested objects.

Five states that are easy to confuse:

| Value | Meaning |
| --- | --- |
| `MISSING` | no initial value; the plan emits `hasDefault: false` and omits `default` |
| `None` | an explicit value; the optional starts disabled |
| `""` | an explicit string; the field is not empty |
| `0` | an explicit integer; the field is not empty |
| `[]` | an explicit empty list; no rows are created |

For a union, the plan records which branch the default selected:
`{"branch": index, "value": ...}`. When the value fits more than one branch,
the first declared branch that accepts it wins — the same ordered selection
the core performs. `list[str] | list[int] = []` opens on `list[str]`; with
the branches swapped it opens on `list[int]`. It only fails when no branch
accepts the value at all.

## `decode()`

```python
decode(schema, data, *, file_resolver=None) -> Any
```

The answer is a dict for the transport `decode()` is written for. The annotation
is `Any` because it is not one for every input: `decode()` prepares and does not
validate, so a `data` that is not a dict at all is handed back as it came, for
`build()` to report rather than for this function to refuse.

`decode()` is the reverse counterpart of `plan_of()`: where `plan_of()` turns a
schema into a plan, `decode()` prepares a JSON-parsed transport object for the
schema to build. It takes the compiled schema (a `Signature` or a `Struct`) and
the raw dictionary that came out of the JSON parser, and returns a **new**
dictionary — the input is never mutated, and nothing in the result is shared
with it. Every container the answer holds is freshly built, a subtree nothing
could route included: that one is copied rather than aliased, so writing into
the result can never reach back into the caller's document.

```python
from pytypehintweb import decode

resolved = schema.build(decode(schema, data))
```

It exists because the transport cannot express, by type, everything the core
demands. JSON has no way to tell `3` from `3.0`, so a float typed in the browser
arrives as an `int`; a date or a time travels as an ISO string where the core
wants a `date`/`time` object; an enum member travels as the name of that member;
and where two options of a union share a portable spelling, the value travels
inside a `$type`/`$value` wrapper that names the one meant. `build()` validates
by exact type and would reject every one of those.

**Restoring that representation is the core's work, and `decode()` delegates
it.** The portable form is written by `pytypehint` — `to_dict()` spells a date
the way decode reads one — and read back by `pytypehint`, in
`Signature.decode()` / `Struct.decode()`. This layer calls that method and adds
nothing to it. One reading of the transport is what keeps the browser and the
core agreeing on what a value means; a second reader here would be a second
answer, differing exactly where it is most expensive to notice — in a value the
two layers file under different options.

What the core deliberately does not own is **storage**. A file reference is a
`str` whose meaning lives in a host's uploads directory or object store, so the
core reads an extension off the text and stops. That is the one thing this layer
adds of its own:

| Step | Owner |
| --- | --- |
| the portable representation: `int → float`, ISO text → `date`/`time`, member name → enum member, and the `$type`/`$value` wrapper | `schema.decode()`, in the core |
| the file references, at every file node of the decoded tree | `file_resolver`, in this layer |
| that the schema is a compiled `Signature` or `Struct` | this layer, with a `TypeError` |
| every rule about the value itself | `schema.build()` |

So `decode(schema, data)` without a resolver **is** `schema.decode(data)` with
that schema check in front of it, and `decode(schema, data, file_resolver=...)`
is the same answer with one further pass over the decoded tree, described in
[`file_resolver`](https://offerrall.github.io/doc/pytypehintweb/python.md#file_resolver) below.

**What arrives, and what it is restored to:**

| Shape at the path | Transport value | Restored into |
| --- | --- | --- |
| `Float` | an `int` (`3`) | a `float` (`3.0`) |
| `Date`  | canonical ISO text (`"2026-07-22"`) | a `date` |
| `Time`  | canonical ISO text (`"14:30:00"`)   | a `time` |
| `EnumShape` | a member name (`"ACTIVO"`) | that member, an alias resolving to the member it aliases |
| `Str` with `FileHint` | a reference (`"informe-<uuid>.pdf"`) | `file_resolver(reference)`, only when a resolver is given (see below) |

The reading is made only where the shape at the path is the single possible one.
Root fields, nested objects, lists (and nested lists), optionals (a `None`
passes through), and union branches are all covered. Everything else passes
through untouched: an enum name that is not a member, a date that is spelled
correctly but is not a real calendar day (`2026-02-31`), a number where a string
belongs — each reaches `build()` as it arrived, and is reported there.

**`decode()` never guesses from a value's content.** A string becomes a `date`
because the *shape* (or an explicit `$type`) says so, never because the string
"looks like" a date. A `str` field carrying `"2026-07-22"` stays a string. This
is the rule that keeps the reading honest, it is inviolable, and it is what the
two paragraphs below are strict for.

**The spelling has to be canonical.** A `date` is read from `YYYY-MM-DD`, and a
`time` from `HH:MM` with optional seconds, an optional fraction of up to six
digits and an optional offset — the forms `isoformat()` writes and the native
pickers produce. Nothing else is a date or a time here. `date.fromisoformat()`
and `time.fromisoformat()` accept far more than that, and their grammars overlap:
`"20200101"` reads as a date *and* as a time, and `"2020"` reads as `20:20`.
Letting them decide would let the *text* of a value select an option, which is
the one thing this pipeline must never do, so a non-canonical spelling travels
intact and `build()` reports it. (The fraction and the offset are read even
though `Time` accepts neither, so the refusal comes from the shape's own rule —
whole seconds, naive — rather than from the value falling through as "not a time
at all".)

**An integer becomes a float only where the two are the same number.** `3`
becomes `3.0` because that is one value written twice. `2**53 + 1` is not: above
`2**53` the floats thin out, so converting it answers with a *neighbour*, and an
integer too large to convert has no answer at all. Both travel intact, because
restoring either would hand `build()` a number the transport never carried — and
`build()` would accept it, which is the one payload this layer must not invent.
The test is exactness, not size.

**`decode()` prepares, it does not validate**, and **it never raises on a value
of its own accord.** A value the core will reject — `"abc"` in a float field,
`"not-a-date"` in a date field, an integer of four hundred digits where a float
is wanted — passes through unchanged, and `build()` reports it with its own
error and its own path. The promise is total: there is no value, of any type,
size or spelling, for which `decode()` raises. (It does raise on a schema that is
neither a `Signature` nor a `Struct`: that is a programming error, not a value.
And a `file_resolver` the host supplies may raise — its exception travels out
untouched.)

### Unions and the wrapper

In a union, the shape alone cannot fix the reading of a bare number or string,
so the transport `$type` does. Two kinds of union collide on the wire: `int |
float` (both a JSON number) and any mix of `str`, `date`, `time` and enums (all a
JSON string). Their branches travel `wrapped`
([plan contract](https://offerrall.github.io/doc/pytypehintweb/plan.md#union-transport)): `{"$type": "float", "$value": 3}`,
`{"$type": "date", "$value": "2026-07-22"}`, `{"$type": "str", "$value":
"2026-07-22"}`, `{"$type": "Estado", "$value": "ACTIVO"}`. The core reads
`$type`, restores `$value` as the branch it names, and consumes the wrapper: the
`float` branch yields a `float`, a `date`/`time` branch a `date`/`time`, an enum
branch the member, a `str` branch its string, and each unwraps to the bare value
the core routes by exact Python type. The `$type` is not discarded — it is
*answered*: the ambiguity it named exists only on the wire.

Two wrappers are **not** consumed, and both refusals are load-bearing.

**A wrapper the core genuinely needs is kept.** `list[str] | list[int]` has
branches of one runtime type, so the discriminator is still what routes them
when `build()` runs; the wrapper stands and only what is inside `$value` is
restored.

**A wrapper whose payload never read as the branch it names is kept too.** In
`str | date`, `{"$type": "date", "$value": "not-a-date"}` names the `date`
branch, and `"not-a-date"` is not a date. Consuming the wrapper there would file
the string under the `str` beside it — silently, and `build()` would accept it,
which is the worst of the available outcomes: the transport said `date` and the
pipeline answered `str` without anyone being told. So the wrapper survives and
`build()` reports it against the branch that was actually named. That refusal
also closes a door on the file side: a wrapper naming a branch its payload did
not reach can no longer deposit a reference into a `FileHint` field behind
`file_resolver`'s back.

Anything that merely resembles a wrapper — an extra key beside the two reserved
ones, a missing `$value`, a `$type` naming no branch of the path, a wrapper
where the path is not a union at all — is malformed transport rather than a
wrapper, and travels whole so `build()` still sees the evidence.

The general rule under all of it: the reading is fixed by the shape, in a union
the `$type` fixes which shape, and the value's content is never consulted.

For the file pass it adds, `decode()` reaches into the core through its public
surface only (`Signature.params`, `Struct.fields`, `Field.shape`, `List.item`,
`Str.file_hint`, `Struct.cls`, `option_id()`): it never touches the core
internals, and it names no shape whose portable spelling the core restores —
importing `Float`, `Date`, `Time` or `EnumShape` here would mean something in
this layer had started deciding what such a value means.

### `file_resolver`

A file reference is a `str` the widget minted locally, and the library has no
opinion on what it names. `file_resolver` is the hook that lets the host give it
one:

```python
def resolve_file(reference: str) -> str:
    return str(uploads_dir / reference)


values = decode(signature, data, file_resolver=resolve_file)
```

```text
without file_resolver
→ references come back as they arrived

with file_resolver
→ every file value is the string the resolver returned
```

It is `Callable[[str], str]`, keyword-only and optional. The reference goes in,
whatever the host returns comes out and continues down the pipeline to
`build()`. There is nothing to register and no type to subclass: the callable is
the whole contract.

The resolver **is** the pass this layer adds, and it runs over the tree the core
has already decoded, so it reaches every position a file node can occupy — a
root field, a `list[File]` (once per reference, in order), a struct field, a list
item, or the selected branch of a union in any of the three transport modes;
where the core kept a wrapper, the pass descends into its `$value` by the same
option id the core matched. What decides the call is the **shape** — a `Str`
carrying `FileHint`, and unambiguously so — never what the string looks like,
which is the same rule that governs the reading before it. An ordinary `str`
field holding something that resembles a reference is untouched.

Absence stays absence: a field the transport does not carry, an explicit `None`,
a switched-off optional and an empty list never reach the resolver. A reference
planted with `setValue()` is an ordinary file value, so it does pass through. So
is a value the core declined to route — a surviving wrapper, a dict where a
dataclass was expected: the pass descends nothing it cannot name, and `build()`
reports what is left.

The library learns nothing about storage in exchange. It knows the value belongs
to a file node; whether that reference becomes a local path, an object-store key
or any other string is the host's business, and so is the failure:

```python
def resolve_file(reference: str) -> str:
    target = uploads_dir / reference

    if not target.is_file():
        raise FileNotFoundError(f"File not found: {reference}")

    return str(target)
```

An exception the resolver raises **propagates unchanged** — `decode()` neither
wraps it nor invents an error of its own. It is the one way `decode()` raises on
a value rather than leaving it for `build()`, and only because the host asked for
it.

func-to-web is one such host: it resolves references against its uploads directory
so the function it calls receives a plain path. Nothing about `decode()` requires
that, or any other particular storage.

## `WebConfig`

```python
@dataclass(frozen=True, kw_only=True)
class WebConfig:
    list_add_label: str = "Add"
    list_remove_label: str = "Remove"
    list_item_label: str = "Item"
    str_min_message: str = "Must contain at least {value} characters"
    str_max_message: str = "Must contain at most {value} characters"
    str_pattern_message: str = "Invalid format"
    int_safe_message: str = "Must be a safe integer"
    int_invalid_message: str = "Enter a valid integer"
    int_min_message: str = "Must be at least {value}"
    int_max_message: str = "Must be at most {value}"
    int_multiple_of_message: str = "Must be a multiple of {value}"
    int_increase_label: str = "Increase"
    int_decrease_label: str = "Decrease"
    float_invalid_message: str = "Enter a valid number"
    float_finite_message: str = "Must be a finite number"
    float_min_message: str = "Must be at least {value}"
    float_max_message: str = "Must be at most {value}"
    date_invalid_message: str = "Enter a valid date"
    date_min_message: str = "Must be on or after {value}"
    date_max_message: str = "Must be on or before {value}"
    time_invalid_message: str = "Enter a valid time"
    time_min_message: str = "Must be at or after {value}"
    time_max_message: str = "Must be at or before {value}"
    list_min_message: str = "Add at least {value} items"
    list_max_message: str = "Keep at most {value} items"
    mode_previous_label: str = "Previous mode"
    mode_next_label: str = "Next mode"
    mode_position_label: str = "Mode {current} of {total}"
    file_invalid_message: str = "Not an accepted file type"
    file_min_message: str = "Add at least {value} files"
    file_max_message: str = "Keep at most {value} files"
    file_min_size_message: str = "File is too small; minimum {value}"
    file_max_size_message: str = "File is too large; maximum {value}"
    file_current_label: str = "Current file: {value}"
    file_current_remove_label: str = "Remove current file"
    file_current_replace_label: str = "Replace file"
    file_current_restore_label: str = "Restore current file"
```

Every visible text the web layer generates is configurable. `WebConfig`
applies to a whole plan, never to individual fields through type hints, and
each `plan_of()` call may use a different one: there is no global state.

Message templates are deliberately minimal. The messages that insert a value
take exactly one `{value}` placeholder and nothing else; the ones that do
not insert a value accept no placeholders at all; literal braces are not
supported. A template that breaks these rules raises `TypeError` when the
`WebConfig` is constructed.

A `Pattern(..., message=...)` beats `str_pattern_message`. The priority is
resolved during plan generation, so a single decided text reaches the
browser.

Structural elements — kinds, transport modes, `$type`, `$value`, property
names — are not configurable, and neither are technical errors: those are
contract violations seen by programmers, not interface text.

Every configured text travels in every plan `plan_of()` emits: the plan is
fully expanded, so a `WebConfig` message or label is written on each node it
applies to, whether or not it differs from the standard text. Each side then
decides whether a given control renders — a slider and a closed choice show no
stepper, so they simply ignore the stepper labels they still carry.

## Errors

`plan_of()` raises `TypeError` at plan-generation time whenever browser
semantics cannot preserve a guarantee the core makes. Nothing is degraded
silently.

It does not independently recreate the core's validation. The layers divide
the work:

```text
pytypehint      core schema semantics
plan_of()       the exact converted browser contract it emits
JavaScript      manual and generated plan input
schema.build()  the values actually submitted
```

A schema that reaches `plan_of()` has already passed the core model, so the
adapter does not restate the core's schema semantics for their own sake. It
verifies the parts of the contract that only become concrete once the plan is
converted for the browser, and rejects:

- JavaScript-unsafe integers, anywhere they appear;
- non-portable regular-expression patterns;
- incompatible control metadata (control combinations asking for different
  widgets);
- duplicate branch identifiers;
- empty nested objects;
- exclusive integer bounds that leave no integer after conversion;
- sliders without both converted bounds;
- sliders with no reachable valid position;
- converted defaults the browser could not represent or validate — including a
  slider default that lands off the `Step` grid (the converted `max` is on that
  grid whether or not the stride divides the range).

The full split is in the
[architecture](https://offerrall.github.io/doc/pytypehintweb/architecture.md#who-owns-which-rule).

Every message is prefixed with the path of the field that caused it, because
in a composed form knowing *what* failed is useless without knowing *where*:

```text
fields.lines.item
fields.address.postal_code
fields.value[int]
```

The paths follow the plan structure: `.name` for a field, `.item` for a list
item, `[index]` for a default element and `[option_id]` for a union branch.

Exact error strings are not part of any compatibility guarantee.

`plan_of()` rejects unsupported types, incompatible control metadata, unsafe
integers, unportable patterns, ambiguous transport identifiers, empty nested
objects, unreachable or unbounded sliders, converted defaults the browser could
not accept, and an invalid `WebConfig`. See
[limitations](https://offerrall.github.io/doc/pytypehintweb/limitations.md) for the full list.
