Skip to content

Schema language

A schema denotes a set of Python values. This page lists every form valgebra reads and the set it denotes. The primary notation is standard typing; compact native forms and the combinators are alternatives for the same sets.

A form this page does not list

The list is exhaustive, so a typing form absent from it is one valgebra does not read. It raises NotImplementedError when the validator is built, not when a value arrives — a schema that cannot be read never becomes one that quietly admits everything.

from collections.abc import Mapping

from valgebra import Validator

try:
    Validator(Mapping[str, int])
except NotImplementedError as error:
    assert "unsupported typing form" in str(error)

The abstract-collection generics are the ones a reader is most likely to reach for: Mapping[K, V], Sequence[T], Set[T], Iterable[T], and the subscripted concrete classes such as deque[T] and OrderedDict[K, V]. These are not built. Whether they should be is open; nothing here rules them out.

type[T] is refused as well, and is a different question: it constrains a value that is itself a class, where every form above constrains a container's contents.

Where the value really is a dict or a list, the builtin form denotes the set you want: dict[K, V] for Mapping[K, V], list[T] for Sequence[T]. Where it is not — a UserDict, a deque — no form here denotes it, and a predicate refinement over the class is the available expression.

Scalars

Schema Denotes
int every int instance
float every float instance
str every str instance
bytes every bytes instance
bool {True, False}
None {None}

The set relationships follow Python's own, exactly:

from valgebra import Validator

# bool is a subclass of int, so True and False are ints
assert Validator(int).is_valid(True)
# int does not subclass float, so an int is not a float
assert not Validator(float).is_valid(1)
assert Validator(float).is_valid(1.0)

Any versus object

Both are the top of the lattice (anything): every value. They build the same schema, and the only difference is the one repr gives back, which is what you wrote (the algebra).

from typing import Any

from valgebra import Validator

assert Validator(object).is_valid(["anything", 1, None])
assert Validator(Any).is_valid(object())
assert Validator(Any) == Validator(object)
assert repr(Validator(Any)) == "Any"

Collections

Schema Denotes
list[T] lists whose every element is in T
set[T] sets whose every element is in T
frozenset[T] frozensets whose every element is in T
dict[K, V] dicts whose keys are in K and values in V
tuple[A, B] length-2 tuples with A then B
tuple[T, ...] tuples of any length, every element in T
tuple[A, B, ...] a fixed prefix A, then zero or more B (see below)
tuple[A, *tuple[B, ...]] the same, spelled by unpacking (3.11+)
typing.List, typing.Tuple, ... the class each aliases, bare or parametrized
from valgebra import Validator

assert Validator(list[int]).is_valid([1, 2, 3])
assert Validator(dict[str, int]).is_valid({"a": 1})
assert Validator(tuple[int, str]).is_valid((1, "a"))
assert Validator(tuple[int, ...]).is_valid((1, 2, 3))
assert Validator(tuple[str, int, ...]).is_valid(("x", 1, 2))

Native forms

A native form exists only where standard typing cannot spell the set: the list literal carries the sequence shapes typing has no syntax for. Everything a typing annotation already expresses is written that way — set[T], not {T}; tuple[A, B], not (A, B); both literals are rejected with a message pointing to the typing spelling.

Native form Denotes
[T] list[T] — a homogeneous list (the single-element idiom)
[T, ...] list[T] — homogeneous, written with the tail marker
[A, B] a fixed-length list, matched positionally (list[A, B] is illegal typing)
[A, B, ...] a fixed prefix, then a repeated tail (see below)
{K: V} dict[K, V]
{"key": T, "key2?": T} a record (see below)
any constant c Literal[c]
from valgebra import Validator

assert Validator([int]).is_valid([1, 2])  # homogeneous list[int]
assert Validator([int, str]).is_valid([1, "a"])  # fixed-length list
assert not Validator([int, str]).is_valid([1])  # wrong length
assert Validator({str: int}).is_valid({"a": 1})  # dict[str, int]
assert Validator("active").is_valid("active")  # the literal "active"

A fixed-length list is matched positionally: element i must satisfy the ith schema and the length must match. typing cannot spell it (list[A, B] is illegal), which is the reason the list literal carries the shape; a fixed-length tuple is the typing tuple[A, B], and the container is part of the type, so a list is never a member of the tuple form and vice versa.

Prefix and repeated tail

A sequence schema is, in general, a regular expression over element types: a fixed positional prefix followed by an optional repeated tail. A trailing ... repeats the element just before it, so [T, ...] (any number of T) is the prefix-free case. The same shape is available for tuples with tuple[A, B, ...]; the container is part of the type, so a tuple is never a member of the list form and vice versa.

Form Denotes
[A, B, ...] a list: an A, then zero or more B
[T, T, ...] a non-empty list of T (at least one)
tuple[A, B, ...] a tuple: an A, then zero or more B
tuple[A, *tuple[B, ...]] the same tuple, spelled by unpacking (3.11+)

An unpacked variadic tuple says the prefix-and-tail shape the way PEP 646 spells it, and Unpack[tuple[B, ...]] is the same thing written out. An unpacked fixed tuple splices its elements in, so tuple[A, *tuple[B, C]] is tuple[A, B, C]. What a sequence cannot carry is an element after the repeating tail — tuple[*tuple[int, ...], str] names a set this algebra does not spell — so that form is rejected rather than read as something else.

from valgebra import Validator

prefixed = Validator([str, int, ...])  # a str, then zero or more ints
assert prefixed.is_valid(["x"])
assert prefixed.is_valid(["x", 1, 2])
assert not prefixed.is_valid([1])  # the prefix must be a str

non_empty = Validator([int, int, ...])  # at least one int
assert non_empty.is_valid([1])
assert not non_empty.is_valid([])

tup = Validator(tuple[str, int, ...])  # the same shape, as a tuple
assert tup.is_valid(("x", 1, 2))
assert not tup.is_valid(["x", 1, 2])  # a list is not a member of the tuple form

unpacked = Validator(tuple[str, *tuple[int, ...]])  # the PEP 646 spelling
assert unpacked.is_valid(("x", 1, 2))
assert repr(unpacked) == "tuple[str, int, ...]"

Literals

Literal[...] denotes a typed singleton: a value is a member iff it has the same type as the literal and is equal to it. The same-type rule keeps Literal[1], Literal[True], and Literal[1.0] distinct, even though Python's == conflates them:

from typing import Literal

from valgebra import Validator

assert Validator(Literal[1]).is_valid(1)
assert not Validator(Literal[1]).is_valid(True)
assert not Validator(Literal[1]).is_valid(1.0)

Literal[nan] denotes nothing. Membership is equality, and a nan is equal to nothing at all — itself included — so the set has no member. That is not a gap in the check: the set is decided empty, and a float still admits nan as it always did. Write float and a predicate if what you mean is "the not-a- number value".

from typing import Literal

from valgebra import Validator

nan = float("nan")

assert Validator(Literal[nan]).is_empty()  # equality admits nothing
assert not Validator(Literal[nan]).is_valid(nan)
assert Validator(float).is_valid(nan)  # the kind still holds it

A string inside a generic is a forward reference, and is refused

The fallback reads a bare value as a literal, and that reading stops at the argument of a typing form. list["Account"] is a forward reference to a type named Account, which the typing spec resolves against the namespace the annotation was written in — a namespace valgebra does not have, because it is handed the runtime object rather than the source. Reading the string as a literal instead would build a list of the word "Account", a schema that refuses what the annotation admits, so the position is refused:

from valgebra import Validator

try:
    Validator(list["Account"])
except NotImplementedError as error:
    assert "forward reference" in str(error)

# Say the type, or say the value.
assert Validator(list[int]).is_valid([1])
assert Validator(["active"]).is_valid(["active"])  # a list of that literal

A class's own annotations are a different matter: Validator(SomeClass) resolves their strings for you (see classes).

Anything unrecognized is a literal

The literal form is also the fallback: an object the frontend does not read as one of the forms above becomes Literal[that object], so Validator(x) denotes {x} for any x valgebra has no other reading for. That is what makes Validator("active") mean the string rather than an error, and it applies to a function, a module or an instance just the same:

from valgebra import Validator


def positive(value):
    return value > 0


schema = Validator(positive)
assert repr(schema).startswith("Literal[")
assert schema.is_valid(positive)  # the function object itself
assert not schema.is_valid(1)  # not a predicate: 1 is not that function

A callable is the case worth naming, because the same callable is a predicate one position inward, as Annotated metadata — see refinements. At the top level there is no base for it to narrow, so the fallback applies and the schema denotes the single function object.

The forms that are refused rather than read as a literal

The fallback is for an object with no other reading. A typing form the spec does give a meaning to is a different case: read as a literal it would denote the form object itself, which no value a caller has belongs to, so the schema would refuse everything without saying why. Each of these is refused at build with a message instead:

Form Why it has no set
Self names the enclosing class, which a schema is built without
LiteralString a property of where a string came from, which a value does not carry
TypeVar, ParamSpec, TypeVarTuple a variable stands for a type and is not one
Final, ClassVar a declaration about a name, not about a value
Unpack[X] binds element types into a tuple[...], so it has no meaning alone
a user Generic[T] parametrisation the parameter is erased at runtime, so the type argument narrows nothing
bare Protocol, and a Protocol without @runtime_checkable membership is isinstance, which such a class refuses to answer
a set or frozen set literal {int} and frozenset({int}) name containers, which are set[T] and frozenset[T]
a tuple literal (A, B) is tuple[A, B]; the list literal [A, B] is the fixed-length list
a frozen dict literal frozendict(a=int) names a record, which the dict literal {"a": int} spells (Python 3.15+)

A qualifier is the exception that is read rather than refused: Required[X], NotRequired[X] and ReadOnly[X] survive hint resolution because field metadata is kept, so the frontend unwraps them and compiles the type they qualify. Written outside a record they are unwrapped just the same, which makes Validator(Required[int]) the same schema as Validator(int).

Unions and Optional

X | Y and Optional[X] denote the union of the member sets:

from typing import Optional

from valgebra import Validator

assert Validator(int | str).is_valid("x")
assert Validator(Optional[int]).is_valid(None)

Records

A dict literal with all-string keys is a record: named fields, closed by default. A required field's key must be present with a matching value; a trailing ? on the key name marks it optional. A closed record admits no key outside the declared names.

from valgebra import Validator

user = Validator({"name": str, "age?": int})
assert user.is_valid({"name": "Ada"})  # optional key absent
assert user.is_valid({"name": "Ada", "age": 36})
assert not user.is_valid({"name": "Ada", "x": 1})  # closed: no extra keys

A key name that ends in a question mark

The suffix is stripped once, so a name that ends in ? is written with one more: {"page??": int} is the optional key page?.

from valgebra import Validator

assert Validator({"page??": int}).is_valid({"page?": 1})
assert Validator({"page??": int}).is_valid({})  # still optional

A field is named once. {"a": int, "a?": str} is two dict keys and one field name, which asks the key to be required and optional at once, so it is refused rather than built into a record admitting nothing:

from valgebra import Validator

try:
    Validator({"a": int, "a?": str})
    raise AssertionError("expected a rejection")
except ValueError as error:
    assert "declared twice" in str(error)

A required key ending in ? has no dict-literal spelling: every trailing ? is the marker, so there is no string that reads back as one. Write it as a TypedDict through the functional syntax, where the key is taken literally and required-ness comes from the class:

from typing import TypedDict

from valgebra import Validator

Query = TypedDict("Query", {"page?": int})
assert Validator(Query).is_valid({"page?": 1})
assert not Validator(Query).is_valid({})  # required

That is the one form repr cannot round-trip: it renders the field as {'page?': int, ...}, which reads back as the optional key page (boundaries).

Open the record with open (undeclared keys admitted) or re-close it with close:

from valgebra import Validator

closed = Validator({"name": str})
assert not closed.is_valid({"name": "Ada", "extra": 1})
assert closed.open().is_valid({"name": "Ada", "extra": 1})

Openness is the default of the keys no clause claims. A clause names a region of the key space — str: int claims the str keys — and the two transforms decide the keys the clauses leave over and nothing else. A record claims no region, which is why opening one frees every key and closing one refuses every key; that is the common case, not the general rule. A mapping claims one, so opening dict[str, int] keeps what a str key maps to and frees only the key-types beside it.

They apply at any depth, including inside a recursive definition. They are projections rather than inverses: applying either twice changes nothing the second time, and close after open is at most close -- it returns the regions open freed wherever opening leaves the term's own names and clauses standing, which is everywhere but the two shapes below.

from valgebra import Validator

# A mapping: the region its clause claims is not the transform's to touch.
mapping = Validator(dict[str, int])
assert not mapping.is_valid({1: "x"})  # no clause claims an int key
assert mapping.open().is_valid({1: "x"})  # opened, that region is free
assert not mapping.open().is_valid({"a": "x"})  # and a str key still maps to int
assert mapping.open().close() == mapping

# `{}` declares no field and claims no region: closed it admits the empty dict
# alone, opened it admits every dict.
assert Validator({}).is_valid({}) and not Validator({}).is_valid({"x": 1})
assert Validator({}).open().is_valid({"x": 1})

# And a clause reads the same whether or not a field is declared beside it.
beside_a_field = Validator({"name": str, str: int})
assert beside_a_field.close().is_equivalent(beside_a_field)
assert beside_a_field.open().is_valid({"name": "Ada", 7: "free"})
assert not beside_a_field.open().is_valid({"name": "Ada", "count": "not an int"})

Where it is not a round trip, the reason is that open normalises, and both normalisations are forced. A declared name admitting everything says what the catch-all an opening writes already says, so it goes -- and it has to, or {"a?": anything} and {}, one set once opened, would close to two. Two clauses carrying one value are one clause over the union of their keys, so opening dict[str, anything] gives a single clause over every key -- and that has to be so too, since the long spelling is keyed by a complement, a shape the set representation declines. Neither is recoverable, so closing afterwards lands on the smaller term:

from valgebra import Validator

record = Validator({"a?": object})
assert record.close().is_valid({"a": 1})  # closing it alone keeps the name
assert record.open().is_valid({"a": 1})
assert not record.open().close().is_valid({"a": 1})  # the name is gone
assert record.open().close() == Validator({})

mapping = Validator(dict[str, object])
assert repr(mapping.open()) == "dict[anything, anything]"  # two clauses, folded
assert not mapping.open().close().is_valid({"a": 1})

dict[str, int] above is the ordinary case: its clause carries a value that is not the top, nothing folds, and the round trip is exact.

To free some key-types and constrain others without open, write the permissive clause yourself as the complement of the keys you constrained — which is what open writes when the regions it frees are not the whole of what is left:

from valgebra import Validator, anything, complement

partly_open = Validator({"name": str, str: int, complement(Validator(str)): anything})
assert partly_open.is_valid({"name": "a", "count": 1})
assert partly_open.is_valid({"name": "a", 7: object()})  # no clause claims it
assert not partly_open.is_valid({"name": "a", "count": "not an int"})

Under a complement, the direction reverses

A record reached through a complement is rewritten like any other. Opening it makes the complemented set larger, which makes the complement — and so the whole schema — smaller:

from valgebra import Validator, anything, complement

not_a_k_record = complement(Validator({"k": anything}))
schema = Validator({"x": not_a_k_record})
value = {"x": {"k": 1, "z": 2}}

# `{"k": 1, "z": 2}` is not a closed `{k: …}` record, so it is in the complement.
assert schema.is_valid(value)

# Opening admits it to the inner record, so it leaves the complement.
assert not schema.open().is_valid(value)
assert repr(schema.open()) == (
    "{'x': complement({'k': anything, anything: anything}), anything: anything}"
)

close reverses under a complement for the same reason: it narrows the inner record, which widens the schema.

So "open admits more" holds of the record the transform rewrites, and of the whole schema only where no complement stands between them. Under a negation the two swap. The practical consequence is that adding or removing an .open() inside a complement changes the schema in the direction opposite to the one the name suggests, and the change is invisible from outside unless you check — repr shows it, as above.

Heterogeneous maps and catch-alls

A dict schema's string keys are named fields; any other key is a schema that keys a default clause for the rest. One form therefore expresses records, mappings, and their combination: several schema keys give a heterogeneous map whose value type depends on which key schema matches, and named fields plus a schema key give a record with a typed catch-all. Named fields take precedence over the catch-all.

Clauses are a disjunction, not a precedence list: a key that is not a named field is admitted when some clause matches both it and its value. So overlapping key schemas widen what the map admits rather than the earlier one winning, and writing them in a different order does not change the schema's meaning.

from valgebra import Validator

# str keys map to ints, int keys map to strs
hetero = Validator({str: int, int: str})
assert hetero.is_valid({"a": 1, 2: "b"})
assert not hetero.is_valid({"a": "x"})  # a str key needs an int value

# a record whose every other key must be an int
extensible = Validator({"name": str, str: int})
assert extensible.is_valid({"name": "Ada", "age": 36})
assert not extensible.is_valid({"name": "Ada", "age": "old"})

The one shape that separates a clause from a field is a literal-keyed clause beside a kind clause that covers the same key. Both clauses read "k", so "k" may carry what either admits, and the schema is the same set as the record whose optional field takes both; the field spelling is a different set, since a field is read instead of the clauses:

from typing import Literal

from valgebra import Validator

both = Validator({Literal["k"]: str, str: int})
assert both.is_valid({"k": "x"})  # the literal clause reads it
assert both.is_valid({"k": 1})  # and so does the str clause
assert not both.is_valid({"k": 1.5})  # neither does
assert both.is_equivalent({"k?": str | int, str: int})

field = Validator({"k": str, str: int})
assert not field.is_valid({"k": 1})  # the field is read, not the clauses
assert field.relation_to(both) == "subset"

A key schema names whole types, not narrowed ones

A clause's key says which keys it governs, and that must be a type — str, int, a union of them, Any — or a Literal, which names the keys one by one. A key narrowed by a constraint is refused where it is written:

from typing import Annotated

import annotated_types as at
import pytest

from valgebra import Validator

with pytest.raises(NotImplementedError):
    Validator({Annotated[str, at.MinLen(2)]: int})

# The two spellings that remain: every key of a type, or one key by name.
assert Validator(dict[str, int]).is_valid({"ab": 1})
assert Validator({"ab": int}).is_valid({"ab": 1})

A narrowed key names part of a type, and two such clauses can overlap without either containing the other — which is a question this map model does not answer the same way twice. To constrain the keys themselves, check them beside the mapping rather than inside it:

from typing import Annotated

import annotated_types as at

from valgebra import Validator

key_shape = Validator(Annotated[str, at.MinLen(2)])
short_codes = Validator(
    Annotated[dict[str, int], at.Predicate(lambda d: all(map(key_shape.is_valid, d)))]
)

assert short_codes.is_valid({"ab": 1})
assert not short_codes.is_valid({"a": 1})

The mapping is still a dict[str, int] to every relation — the predicate is opaque, as every predicate is (refinements), so the keys are checked on membership and say nothing about inclusion. That is the whole trade, and it is why the narrowed key is refused rather than compiled into something that looks decided and is not.

Constraining some keys and freeing the rest

Because the clauses are a disjunction, a clause that matches every key subsumes every narrower one. So freeing the keys beside a typed clause means a clause over the complement of the keys that one claims: disjoint clauses cannot widen each other, and the constrained keys stay constrained.

That is what open writes. Written by hand it is how a caller frees some of what is left rather than all of it:

from valgebra import Validator, anything, complement

# str keys must be ints; any key that is not a str is unconstrained.
partly_open = Validator({"name": str, str: int, complement(Validator(str)): anything})
assert partly_open.is_valid({"name": "Ada", "age": 36})
assert partly_open.is_valid({"name": "Ada", 7: object()})  # no clause claims it
assert not partly_open.is_valid({"name": "Ada", "age": "old"})

# And `open` frees the same region, so it writes the same schema.
assert Validator({"name": str, str: int}).open().is_equivalent(partly_open)

A schema written this way leaves the decided fragment: a clause keyed by a complement is one the set representation declines, so relations about it fall back to the rules. Membership is unaffected — the walk reads the value.

Classes

Form How it validates
TypedDict a record, open as the typing spec defines one; Required/NotRequired/ReadOnly honored, closed=True/extra_items obeyed
dataclass isinstance plus a deep check of each declared field
NamedTuple isinstance plus the tuple its fields lay out, checked by position
Enum an instance of the enumeration (any member)
runtime-checkable Protocol isinstance against the protocol
NewType validates the supertype it wraps
PEP 695 type alias validates the aliased type, and ties the fixpoint where the alias names itself (recursion)
import enum
from dataclasses import dataclass

from valgebra import Validator


class Color(enum.Enum):
    RED = 1
    GREEN = 2


@dataclass
class Point:
    x: int
    y: int


assert Validator(Color).is_valid(Color.RED)
assert Validator(Point).is_valid(Point(1, 2))
assert not Validator(Point).is_valid(Point(1, "y"))

What a class declares is not every annotation on it. A ClassVar annotates the class and an InitVar names a constructor parameter, so neither is an attribute of an instance and neither is checked; a field declared init=False is on the instance and is.

That last one has an edge, and it is the check-only semantics showing through: a field with init=False and no default is not set by the constructor, so unless __post_init__ assigns it the attribute is absent from the instance — and an absent attribute is not a value of any type. valgebra reads the object it is given rather than the declaration, so such an instance is not a member until something assigns the field.

from dataclasses import dataclass, field

from valgebra import Validator


@dataclass
class Row:
    key: int
    seen: bool = field(init=False)  # no default: the constructor sets nothing

    def touch(self) -> None:
        self.seen = True


row = Row(1)
assert not Validator(Row).is_valid(row)  # `seen` is not there yet
row.touch()
assert Validator(Row).is_valid(row)

On a TypedDict, Required, NotRequired and ReadOnly qualify the key rather than narrowing its type: required-ness is read from the qualifier where the field carries one and from the class otherwise, and read-only-ness is about writing the key back rather than about which values belong. Reading the qualifier is what makes a class mean the same thing under from __future__ import annotations, where the class's own key sets are computed from strings and cannot see it.

A TypedDict is open, a dict literal is closed

They denote different sets, and each denotes what its own author's spec says.

from typing import TypedDict

from valgebra import Validator


class User(TypedDict):
    name: str


assert Validator(User).is_valid({"name": "Ada", "note": "extra"})
assert not Validator({"name": str}).is_valid({"name": "Ada", "note": "extra"})

Validator(TD) reads an annotation whose meaning is fixed by the typing spec, and the spec makes a TypedDict open — reading it as a narrower set would be a deviation the class carries no mark of. The dict-literal form is this library's own spelling, and a schema written as a shape means that shape. Both sets are spellable both ways: write closed=True (PEP 728) for a closed TypedDict, and {"name": str, anything: anything} for an open shape -- or .open(), which does the same to every record in a schema at once.

Pass the class, not its annotations

Give Validator the class itself. It reads the annotations, resolves the string forms, and keeps the Annotated metadata — which is where every refinement lives.

Extracting the hints yourself and passing the mapping is the path that goes wrong, because typing.get_type_hints drops Annotated metadata by default. The schema still builds and still validates; it has quietly lost its constraints, and nothing raises:

from typing import Annotated, TypedDict, get_type_hints

import annotated_types as at

from valgebra import Validator


class Account(TypedDict):
    balance: Annotated[int, at.Ge(0)]


assert not Validator(Account).is_valid({"balance": -5})  # the class: constrained

stripped = get_type_hints(Account)  # {'balance': <class 'int'>}
assert Validator(stripped).is_valid({"balance": -5})  # the bound is gone

kept = get_type_hints(Account, include_extras=True)
assert not Validator(kept).is_valid({"balance": -5})  # include_extras keeps it

If you must derive the hints — building a schema for a class chosen at runtime, say — pass include_extras=True. Passing the class is the supported path and has no such footgun.

Recursive classes

A class whose own type appears in a field (a tree node, a linked list) is recursive and cannot compile directly — express it with recursive, which ties the fixpoint explicitly.

Bare classes, callables, and the runtime boundary

A bare class is an isinstance check: Validator(complex) admits any complex, and any user class admits its instances. Callable (and Callable[...]) checks only that the value is callable — the argument and return types cannot be inspected at runtime, so they are not enforced. Any is admitted unchecked. Everything else is decided structurally: a list[int] schema does check each element.

Refinements

Annotated[T, ...markers] narrows T with constraints — bounds, lengths, multiples, and predicates. See the refinements guide.

Stable repr

A compiled validator prints back as an expression that builds it, which makes schemas inspectable — and makes what is printed something you can paste into a session and get the same schema from:

from valgebra import Validator, anything, recursive

assert repr(Validator(list[dict[str, int]])) == "list[dict[str, int]]"

# A record's fields print in name order, whatever order they were written in:
# they are a *map*, so the order is not part of the schema, and two spellings of
# one record are one schema.
assert repr(Validator({"name": str, "age?": int})) == "{'age?': int, 'name': str}"
assert Validator({"name": str, "age?": int}) == Validator({"age?": int, "name": str})

# A recursive schema prints as the call that builds it, the back edge as the
# lambda's own parameter.
tree = recursive(lambda t: {"value": int, "left?": t})
assert repr(tree) == "recursive(lambda X: {'left?': X, 'value': int})"
assert recursive(lambda X: {"value": int, "left?": X}) == tree

# An open record prints the catch-all it carries, so it reads back as itself.
opened = Validator({"name": str}).open()
assert repr(opened) == "{'name': str, anything: anything}"
assert Validator({"name": str, anything: anything}) == opened

It is a rendering, not a serialization. Four forms cannot be written as an expression and do not read back: a class, which prints as its name; a predicate, which prints as Predicate(...); a constant too long to print, which is cut; and a schema deeper than the renderer's own bound, which gives up and prints <...> — a mark chosen to be a syntax error, so a render that lost something cannot be read back as a schema that kept it. The API page has each with the reason. Do not parse a repr to recover structure — see inspection for asking a schema questions instead.