Atoms

August 10, 2026 · View on GitHub

Atoms are frozen values inside Annotated. Limits affect validation; notation is stored for wrappers and ignored by validation.

ShapeAccepted atoms
IntMin, Max, Choices, MultipleOf, Step, Slider, Placeholder, Extra
FloatMin, Max, Choices, Step, Slider, Placeholder, Extra
StrMin, Max, Choices, Pattern, FileHint, IsPassword, Rows, Placeholder, Extra
Date, TimeMin, Max, Choices, Placeholder, Extra
ListMin, Max for length, Extra
Bool, NoneShapeExtra
EnumShapeExtra
dataclass (Struct)none; annotate struct fields, not nesting
any fieldLabel, Description
optional field (X | None)OptionalToggle

Atoms

Min(value, *, exclusive=False) and Max(...) set lower and upper bounds. On strings and lists they constrain length and cannot be exclusive.

Choices(values=(...)) requires a non-empty tuple of unique, hashable values. Choices must have the shape's exact type and satisfy its other limits.

MultipleOf(value) accepts a positive integer and applies only to Int. Integer-only divisibility avoids floating-point ambiguity.

Pattern(regex, *, message=None) applies a full regular-expression match to Str. A custom message replaces the standard mismatch message.

Step(value) is positive numeric wrapper notation. Slider(show_value=True) is notation for numeric fields and requires both Min and Max.

Placeholder(text) is non-empty wrapper notation for scalar inputs. IsPassword() and Rows(n) are string notation; rows must be positive.

Extra(key, value) carries wrapper notation the vocabulary does not name. The key is namespaced — "package.name", the dot required — so that two packages annotating one field cannot collide by accident and every entry names its owner. The value is any string, empty included: the core stores it and never reads it, so what an empty value means is the wrapper's business.

Several Extra atoms on one hint merge by key. The shape stores them as a sorted tuple of pairs, which keeps it hashable and its equality independent of the order the atoms were written in, and exposes them as an extras dict rebuilt on each access. That dict is a detached snapshot rather than a view: writing to it, inserting into it or clearing it is allowed and never reaches the shape. Filtering by namespace is the wrapper's job: {k: v for k, v in shape.extras.items() if k.startswith("ledform.")}.

FileHint(extensions=(), min_size=None, max_size=None) marks a str whose text names a file, and carries that file's contract: the suffixes the name may take and the byte sizes the file must fall between. Extensions are lowercase dotted suffixes and may not repeat; sizes are byte counts, each int or None, never negative, and min_size may not exceed max_size (bool is not an int here). Validation checks the extension against the text of the value, and stops there.

The value is and remains exactly str. Nothing is coerced to pathlib.Path, normalized, resolved, expanded or made absolute, so the string that arrives is the string that validates and the string that is served.

from typing import Annotated
from pytypehint import FileHint

FilePath = Annotated[
    str,
    FileHint(
        extensions=(".pdf",),
        max_size=10 * 1024 * 1024,
    ),
]

Every validation the core performs is answered by the schema and the value between them, and this atom is where that line is easiest to see. An extension is text: "report.pdf" ends in .pdf or it does not, and the answer is the same in every process, on every machine, with or without a disk attached — so the core answers it. Existence and size are the world: they need a filesystem, a working directory and a moment in time before they mean anything at all. Those two are therefore stated rather than checked. They travel in the document as contract, for the wrapper to apply where it has the file in hand — at the upload, at the command-line argument, at the request that actually opens the bytes.

The division is the general rule applied to one atom, and philosophy.md sets out why it is a gain rather than a renunciation: a fact about the world is only true where and when it is read, and a validator that reads outside the process takes the determinism of the document with it. The core describes the file; the wrapper verifies it.

Label(text) and Description(text) are non-empty field-level notation.

OptionalToggle(enabled) is field-level notation for X | None. True starts a wrapper toggle on, False starts it off, and absence leaves the choice to the wrapper. It never changes resolution or defaults.

Compile-time cross-checks

The schema rejects empty ranges; choices outside bounds or failing pattern, multiple or file rules; ranges containing no valid multiple; sliders without both bounds; wrong bound types; and OptionalToggle on a non-optional field. A choice under FileHint must carry an accepted extension, and so must a certified default — that is the whole of the contract the core can settle here. These contradictions fail during schema compilation because a compiled schema must be structurally valid. Compilation rejects contradictions it can determine exactly; it does not attempt a general satisfiability proof across constraints such as a regular expression combined with length bounds.

Unsupported metadata reports unsupported metadata for <type>: <atom>. Metadata across a multi-type union must be placed per option.

Layering

For repeated atom classes, the outer layer wins; within one layer, the rightmost atom wins. The rule applies uniformly to limits and field notation:

from typing import Annotated
from pytypehint import Max, Min, OptionalToggle

Percent = Annotated[int, Min(0), Max(100)]
Narrow = Annotated[Percent, Max(50)]

Optional = Annotated[int | None, OptionalToggle(True)]
Closed = Annotated[Optional, OptionalToggle(False)]

Extras layer per key rather than per Extra class: different keys accumulate on the shape, and a repeated key follows the rule above. Outer and rightmost are one mechanism here — typing flattens Annotated before compilation, so the outer layer is the rightmost atom:

from typing import Annotated
from pytypehint import Extra

Themed = Annotated[int, Extra("ledform.color", "red"), Extra("ledform.rows", "2")]
Blue = Annotated[Themed, Extra("ledform.color", "blue")]
# extras == {"ledform.color": "blue", "ledform.rows": "2"}

Conflicting field atoms hoisted from different union options fail with conflicting ... across union options; an explicit outer atom overrides them.