# Limitations
Everything a schema carries that the browser cannot represent faithfully
makes plan_of() raise TypeError — with one exception,
a recursive shape, which meets the interpreter's stack
limit before any check of ours can name it. Nothing is degraded silently, so the
limitations below are visible at plan-generation time rather than at render
time.
# Scalar types
The scalar types are str, int, float, date, time, bool and enum
(any non-Flag, non-empty Enum). They compose through lists, nested lists,
optional values, unions, dataclasses and function signatures.
Any other scalar shape raises:
... is not supported yet
# Float slider
Slider on a Float is rejected:
Float.slider is not supported yet
A slider is a grid of min + k*step positions plus max, and float arithmetic
cannot walk that grid exactly: min + k*step accumulates rounding, so a legitimate default
could fall just off a position and be refused, or the control would have to
silently snap a value to the nearest step. The doctrine forbids both — no
rejecting a valid value, no silent correction — so a float slider is not offered
rather than offered wrongly. The float node carries no slider property at
all, so a hand-written plan that adds one is rejected as an unknown property,
with no special rule needed.
# decode() scope
The transport cannot express every type the core validates by exact type, so
decode() prepares a JSON-parsed object before
schema.build(). What it prepares is not this package's to decide. The
portable representation — int → float where the shape is Float, ISO text →
date/time where the shape is Date/Time, a member name → enum member
where the shape is EnumShape, and the $type/$value wrapper that names an
option the value alone cannot — is restored by schema.decode() in the core,
and the web layer adds exactly one thing to it: the file references, resolved
through file_resolver. So the scope of the reading is the core's scope, and it
widens when the core's does rather than when this package decides it should. The
limit that does belong here is the file one: nothing resolves a reference unless
a host supplies the callable, because neither library knows what storage means.
The reading is made only where the shape at the path (or an explicit $type in
a union) fixes it, never by inspecting a value's content — a str that looks
like a date, or matches a member name, stays a str. Everything the schema
cannot name a reading for passes through unchanged for build() to judge, and
three consequences of that are worth stating as limits rather than left to be
discovered:
- A date or a time is read in its canonical spelling only —
YYYY-MM-DD, andHH:MMwith optional seconds, fraction and offset.fromisoformat()accepts far more, and the two grammars overlap ("20200101"reads as a date and as a time), so letting it decide would let the text of a value select an option. A producer that spells one of them some other way gets its string back and abuild()error, not a silent reading. - An integer becomes a float only when a float equals it exactly.
2**53 + 1travels as it came, and so does an integer too large to convert at all: restoring the neighbouring float would handbuild()a number the transport never carried, andbuild()would take it. - A wrapper whose payload did not read as the branch it names survives. The
transport said
dateand the value is not one, so nothing files it under thestrbeside it; the dict reachesbuild()whole and is reported there. This is also what keeps a file reference from reaching aFileHintfield without passing throughfile_resolver.
One property of decode() is a guarantee rather than a limit, and it is worth
recording beside them: it never raises on a value, whatever that value's
type, size or spelling. Every refusal of a value is build()'s.
# Recursive shapes
A dataclass that refers to its own type — directly, or through another dataclass
that leads back to it — compiles in the core and is describable there: the
core's portable contract writes each dataclass once into a definition table and
lets a field point at it, so to_dict() represents the cycle as a reference and
terminates.
A plan is not that format. It is fully expanded: every node carries its own
options, messages and defaults written out in place, with nothing to point at
and no table to point into, because the browser reads a plan top to bottom and
resolves no references. A shape that contains itself has no expanded form — the
expansion does not terminate — so plan_of() cannot produce one:
@dataclass
class Node:
name: str
child: "Node | None" = None
struct_of(Node) # compiles
struct_of(Node).to_dict() # the child field carries a reference back to Node
plan_of(Node) # RecursionError
This is the one limit on this page that does not arrive as a TypeError naming
the offending path. The recursion is caught by the interpreter's stack limit
rather than by a check that knows what it is looking at, so what surfaces is a
bare RecursionError with no field coordinates in it. Finding the cycle before
descending into it is a check that has not been written; a recursive form has no
representation waiting behind it either way, so what is missing is the
diagnosis, not the feature.
A form over a recursive structure needs a shape with a bound: a fixed depth
spelled out as distinct dataclasses, or a flat list of nodes carrying a parent
key. Both are representable and both expand.
# Regular expressions
Pattern support is a conservative portable subset: the core validates with
Python's re and the browser runs RegExp, and only constructs that behave
identically in both engines are accepted (a pattern that merely compiles in
JavaScript is not enough). The full list of rejected constructs and accepted
escapes lives in the plan contract.
Most rejected shortcuts have a portable equivalent that spells out the intended characters, which also documents the pattern:
| Common shortcut | Portable form |
|---|---|
\d |
[0-9] |
\D |
[^0-9] |
\w |
an explicit class for the domain, e.g. [A-Za-z0-9_] |
\s |
an explicit class of the spaces you accept, e.g. [ \t] |
. |
an explicit class of the allowed characters |
Spelling the class out is not only portable, it is usually the more honest
pattern: \d matches digits in many scripts, while a form field almost always
means [0-9]. The library ships two convenience patterns — COLOR_PATTERN and
EMAIL_PATTERN, behind the Color and Email aliases (see
python.md) — and no more. They are format filters, not
validators: EMAIL_PATTERN in particular rejects plenty of RFC-valid addresses
and accepts plenty of nonsense, and a generic "email" regular expression is
neither simple nor universally correct. The subset may become stricter if another
divergence is found; it is not guaranteed to grow.
# Safe integers
Every integer that reaches a plan must fit JavaScript's safe integer range,
from -9007199254740991 to 9007199254740991. Bounds, multipleOf, step,
choices, list lengths and defaults at any depth go through the same check.
This does not restrict pytypehint, which keeps accepting arbitrary
integers. It only prevents sending a value the browser would round.
Exclusive bounds are converted to inclusive ones before that check, so an
exclusive limit can fail because the inclusive value it needs falls outside
the range. This conversion applies to integer bounds. String and list length
bounds are inclusive in the browser contract and the core does not allow
exclusive length bounds; the adapter rejects one defensively rather than
dropping the exclusive flag and weakening the bound.
An ordinary integer range with a multipleOf must contain at least one value
that satisfies it: Min(1), Max(4), MultipleOf(7) has none and is rejected —
by the core, when it compiles the schema, so the adapter and the browser
validator do not re-check it. This is range membership, distinct from a slider,
whose default must land on a min + k * step position. A Slider with a
Placeholder is also rejected — a range input has no placeholder — alongside
the other control combinations below.
The browser validator (checkPlan) validates everything a hand-written expanded
plan needs to be buildable — structure, the network boundary, and the semantics
of the normalized document — but it does not restate schema-compiler invariants
that cannot affect runtime integrity. So a valid manual plan is not the same
as a form that can reach ready: a hand-written plan can carry a constraint that
is merely unsatisfiable (never valid) — an unreachable ordinary multipleOf
range, say — and checkPlan accepts it, because it corrupts nothing it
transports; the widget simply never becomes ready.
# Producing plans in Python
plan_of() is the only Python producer, and there is no standalone Python plan
validator: a plan written by hand or by another backend is checked in the
browser, by checkPlan(), which compileForm() calls before building any
widget.
# Message templates are not a translation engine
Validation messages are simple templates with a single fixed placeholder —
{value} for the value-bearing ones, {current} and {total} for the mode
position. They do not pluralize, decline or reorder for grammar, so a default
such as Add at least {value} items reads awkwardly at {value} of one and in
languages where the noun agrees with the number.
The intended answer is wording that stays correct across counts rather than a
built-in i18n layer. A neutral phrasing sidesteps the problem — for example
Minimum number of items: {value} — and an application that needs full
pluralization can supply its own messages through WebConfig or render its own
below the widgets.
# Unsupported metadata
Extrais not interpreted, including on an enum. An enum shows its raw member names, and theenumnode'slabelsslot is alwaysnull.- Metadata combinations that ask for different controls are rejected rather
than resolved by the adapter:
Rows,IsPasswordandChoiceson a string, andChoiceswithSlider,ChoiceswithPlaceholder, orSliderwithPlaceholderon an integer. - Branches that share a transport type and an option id are rejected, because nothing downstream could tell them apart.
# File fields
How a file field works — its two reference origins, the reference minting, the Replace/Restore cycle and the host's upload loop — is the JavaScript API's to describe. What is limited here:
- A file composes like any other node, at any depth: inside a list, a
dataclass, an optional or a union branch, and inside combinations of those.
A bare
list[File]is the one shape with a dedicated representation — a singlemultiplewidget rather than a list of single-file widgets — and that is a shortcut, not a restriction on the others. The only file combinations still refused are the ones listed here as genuinely unrepresentable: theStratoms besideFileHint, and a union whose branches share a transport (see below). The byte-size bounds are not among them — they travel; what is limited is who can act on them. File | str— and solist[File | str]— is inconstructible, and the core says so: a file is aStr, so both branches carry the option id"str"and the schema fails to compile (duplicate option types in shapeon a field,both compile to stron a list's items). Nothing can tell the two apart on the wire, so this is a real ambiguity rather than a missing feature. It does not extend toFile | NoneorFile | int, whose branches are distinguishable and both work.- A file default is an existing reference, the same thing
setValue()takes, and it is checked exactly as far as text can be checked: it must be astrwhose extension is accepted. Nothing verifies that bytes stand behind it — not the browser, which never saw them, and not the core, which opens nothing. A reference that was never uploaded, that has expired or that belongs to somebody else renders like any other and travels back intact. FileHint(min_size=...)/max_size=...travel, but only a local pick can be weighed against them, because only a localFilecarries a.size. A reference carries no bytes, so a form can show one that breaks a bound and nothing downstream will object. A bound larger than a safe JavaScript integer is refused rather than rounded.
A reference is not a path, and nothing between the browser and the core can
close that gap. All the widget ever checks is the extension — a lenient
endswith filter — and the core checks the same extension on the same text.
Neither of them knows whether bytes were stored, and neither claims to: the core
opens no files and checks neither existence nor byte size, so an unstored
reference builds into the plain string it is. Deciding
whether a reference is real is the host's, at the only point where code that
knows the storage sees it — decode(..., file_resolver=...), which propagates
whatever the host raises. A wrapper such as func-to-web that builds the upload
cycle owns that decision. The full cycle is in
Values completed outside the browser.
FileHint(min_size=...) and max_size=... are emitted, as minSize and
maxSize, and the widget weighs a chosen File against them so the bytes never
move when the answer is already no. That is also where they stop: a reference
names a file the browser never saw, so it is not weighed, and neither is a
default. There is no second opinion further down — a byte bound that has to hold
authoritatively belongs beside the storage that holds the bytes.
# Static data only
Choices (on any scalar, dates and times included), Literal[str] and
Literal[int] describe a closed set of values that is known when the plan is
generated. A plan carries no URL, no query and no callback, so a select cannot
load its options from a remote source or depend on the value of another field.
Filling a select from live data means generating a new plan with the values
already in it, or building the widget directly with StrChoiceWidget,
IntChoiceWidget or FloatChoiceWidget (a date/time select is a
StrChoiceWidget over ISO strings, and an enum select is a StrChoiceWidget
over member names). An enum's set of members is fixed in the type, so it is
static by nature.
# Accessibility
The widgets expose names, descriptions, invalid state and grouping through standard attributes on native controls. What is guaranteed, and what is not covered (no WCAG statement, no real-browser test suite, focus-after-removal left to the browser, page-level concerns owned by the host), is in the JavaScript API.
# Text rendering, not sanitizing
Plan strings are always inserted as text and never parsed as markup, so a
label containing HTML shows the characters instead of rendering them. That is
a rendering guarantee, not a content filter: nothing is stripped, escaped or
rewritten, and form.read() returns exactly what the user typed.
Deciding whether a plan comes from a trustworthy producer, and validating the transport object once it leaves the browser, remain the application's job.
# Distribution
There is no npm package. The browser modules live inside the Python package,
under pytypehintweb.STATIC, and are meant to be served or copied as plain
static files. They are ES modules with no build step, no bundler configuration
and no type declarations.
# Browser support
The JavaScript runtime uses standard ES modules, classes, nullish
coalescing, WeakMap, BigInt and the u regular expression flag. It targets
current versions of the major browsers.
The optional widgets.css stylesheet additionally uses custom properties,
the CSS :has() selector, :not() with a complex argument and mask-image
(with the -webkit- fallback), so it requires a
current browser. This is a stylesheet requirement only: the widgets remain
semantically correct and keyboard-operable without the stylesheet, so a browser
lacking them still runs the library — it only loses some of the polished
styling. There are no CSS fallbacks or polyfills, and no light-dark(): the
themes are explicit blocks, so a subtree can be themed without depending on a
global color-scheme.
The stylesheet also expects the widgets to be mounted inside a .pth-root
container; outside one they are unstyled rather than half-styled. Its theme
contract is exactly .pth-root, .pth-root[data-pth-theme="light"] and
.pth-root[data-pth-theme="dark"]; no other attribute or class selects a theme.
The stylesheet needs its icons/ subdirectory, served beside it; see
Icons.
The Node suites run against a lightweight fake DOM, so they cover widget logic, state and transport; a headless-Chrome smoke page and a theme-cascade page cover the few behaviours only a real browser has (see Testing). No specific browser version is claimed; the bundled demo is the practical way to check a target browser.