The portable contract
August 10, 2026 · View on GitHub
Struct.to_dict() and Signature.to_dict() write the compiled schema as a
portable tree — dicts, lists, strings, numbers, booleans and null, and nothing
else. It is the contract as data: what the schema accepts, and nothing about how
anyone should present it.
import json
from dataclasses import dataclass
from datetime import date
from pytypehint import struct_of
@dataclass
class User:
name: str
birthday: date
json.dumps(struct_of(User).to_dict())
{"v": 1, "kind": "struct", "root": "User",
"defs": {"structs": {"User": {"name": "User", "fields": [
{"name": "name", "shape": [{"type": "str"}]},
{"name": "birthday", "shape": [{"type": "date"}]}]}},
"enums": {}}}
What it is for
The core compiles a contract; every wrapper has had to learn how to read it, and
each learned separately. A form generator, a command line, a client in another
language, a store that names a file after the shape of its rows — all of them
need the same description, and none of them needs the same presentation. This is
the description. Presentation stays where it belongs: pytypehintweb decides
what a control looks like, a CLI decides what a flag looks like, and the core
decides neither.
So the format carries no inputType, no widget, no HTML, no CSS, no ordering
hints, no layout, and none of the wording a medium invents for itself — the text
of a range error, the caption on a stepper, the label of an "add row" button.
What it does carry is every atom the author wrote, including the notation ones:
Label, Description, Placeholder, the message on a Pattern, and also
Rows, Step, Slider and IsPassword. None of those affect validation, and
naming them here is not a lapse — they are part of the closed vocabulary the core
admits precisely because several rendering contexts agree on what they mean, and
the author put them on the field deliberately. atoms.md sets the bar
they had to clear.
The line is not "does it concern presentation" but "who decided it". An author
writing Rows(5) is a fact about the field, and the document reports it. Whether
that becomes a textarea, a repeated flag or five lines of a TUI is the wrapper's
call, and the document says nothing about it.
Everything a particular medium needs beyond the contract travels through Extra,
which the core stores verbatim and never reads — see atoms.md.
Two guarantees
It is deterministic. Equal definitions produce equal documents, byte for
byte, across processes and hash seeds. Every key is written in a fixed order,
every sequence keeps the order the author wrote, and nothing derived from id()
or from set iteration reaches the output. json.dumps needs no sort_keys. That
is what makes the document usable as a fingerprint, a cache key, or the two sides
of a diff. Nothing outside the process is consulted at any point, and that holds
by construction rather than by care: there is no filesystem, no clock, no
environment and no working directory anywhere in the emission, because a compiled
schema itself holds nothing that was read from them. Even the one thing the format
decides rather than copies — which option of a slot a default inhabits — is
decided from the schema and the value alone.
a = struct_of(User).to_dict()
b = struct_of(User).to_dict()
assert json.dumps(a) == json.dumps(b)
One caveat, and it belongs to the interpreter rather than to the emitter: a
signature's doc is the function's __doc__, and python -OO discards every
docstring, so under that flag the key is absent. Documents are comparable across
runs of the same interpreter configuration, not across a change of it.
Each call returns a fresh tree. Every dict and every list in the result is new, at any depth, so the caller can rewrite the tree wherever it likes without reaching the schema or the next call:
document = schema.to_dict()
document["defs"]["structs"].clear() # harmless
assert schema.to_dict() == original
Nothing is cached between calls. Two callers never receive the same tree.
The leaves are another matter, and deliberately so: a label, a pattern, a
choices entry and a text default are the very string objects the shape holds.
Sharing them is what makes the guarantee cheap instead of costly — a value that
cannot be mutated cannot be observed to be shared, so copying it would buy the
caller nothing it does not already have.
It speaks decode's language
A date is written the way decode reads one, an enum member by the
name decode looks up, and a value that would be ambiguous in a portable tree
carries the same $type/$value wrapper decode reads. So a default taken from
this document is valid input to the pipeline, with no translation in between:
@dataclass
class Trip:
leaves: date = date(2026, 8, 8)
schema = struct_of(Trip)
written = schema.to_dict()["defs"]["structs"]["Trip"]["fields"][0]["default"]
# "2026-08-08"
schema.build(schema.decode({"leaves": written})) # Trip(leaves=date(2026, 8, 8))
decode alone already lands on the exact value in almost every case. Two
exceptions survive it, and both are removed by build rather than by decode,
because both are things validation still needs:
- a slot whose options share a Python runtime type keeps its wrapper. Those are
exactly the slots with two or more
listoptions — no other pair of options can share a Python type and still be distinguishable, soduplicate option typeswould have rejected them; - a nested dataclass stays a dict until
buildconstructs it, keeping its inline$typeif it had one.
That is why the round trip is stated through build. One portable language,
written by to_dict and read by decode.
Version
{"v": 1, ...}
v rises only when a key already in the format changes meaning or leaves. New
keys do not raise it, so a reader must ignore keys it does not know — that is
what makes an addition compatible. v is the version of the format, never of
the library: emitting a library version would change every document on every
release and is provenance, not contract.
Documents
Two kinds, told apart by kind.
{
"v": 1,
"kind": "struct",
"root": "Cart", // an id in defs.structs
"defs": {"structs": {…}, "enums": {…}}
}
{
"v": 1,
"kind": "signature",
"name": "upload", // an identifier
"doc": "Upload a document.", // omitted when the function has no docstring
"params": [ <field>, … ],
"defs": {"structs": {…}, "enums": {…}}
}
A struct's fields live in the table because a recursive field points back at the root and needs somewhere to point. A signature's parameters are written in place, because nothing can hold a reference to a signature.
defs is always present with both tables, even empty. It is the document's
skeleton rather than data, and a shape that varies would make every reader write
doc.get("defs", {}).get("enums", {}).
Definitions
"structs": {
"Cart": {
"name": "Cart", // cls.__name__ — the string the wire carries as $type
"fields": [ <field>, … ]
}
},
"enums": {
"Role": {
"name": "Role",
"members": ["ADMIN", "USER"] // canonical members, in definition order
}
}
Always a reference, never inlined. A struct is written once and pointed at from every occurrence. This is what makes recursion terminate and what keeps a shared dataclass from being copied: a graph of twenty structs where each field reuses the previous one has two million nodes if inlined, and twenty-one if referenced. It also leaves one code path in the reader — resolve the id — instead of "does this occurrence carry the definition or a pointer?".
Ids and names are different things. The name is contract: it is what
travels as $type. The id is a handle local to this document. They are usually
the same string, but two classes of the same name can sit in one schema whenever
they are not options of one field, and no name distinguishes them — two classes
built by the same factory share __module__ and __qualname__ too. So the id is
the class name made unique within the document by the order it was first reached:
"Target", then "Target#2". Read the name; use the id only to follow a ref.
Structs and enums have separate tables because they have separate discriminators:
a dataclass and an enum of the same class name never compete for one $type, and
the core admits that pair on purpose.
Enum members are names only. A member's value may be a tuple or an object, which
a portable tree cannot carry, and the name is what identifies a member anyway —
EnumShape accepts a member by its exact type, never by its value. Aliases are
left out: an alias names a member already listed, and decode still accepts one
because the enum resolves it to that member.
Fields
{
"name": "age", // always
"label": "Age", // omitted when absent
"description": "…", // omitted when absent
"optional_toggle": false, // omitted when absent; false is written
"default": 18, // omitted when there is no default
"shape": [ <node>, … ] // always an array, at least one, author's order
}
shape is an array even for a single option, so a reader has one code path
rather than two.
There is no optional or required key. Optionality means different things
to a form, to a patch endpoint and to a validator of raw payloads, and all three
are right — philosophy.md declines to choose for them. The raw
structure does not opine: a {"type": "none"} among the options says one thing,
the presence of default says another, and the reader concludes. to_dict does
not decide where the core refuses to.
Absence, null, and false
An absent atom omits its key. It is never written as null, because two
spellings of one fact would stop the document being canonical and break diffs,
hashes and comparisons.
null therefore means exactly one thing, in exactly one place: "default": null
says the default is None. A field with no default omits the key entirely, which
is how MISSING is expressed — the two are different states and both are real.
Values equal to their own default are omitted too — no "exclusive_min": false,
no "extras": {}. Three exceptions, each because the value is information:
"optional_toggle": false— absent, true and false are three states;"slider": {"show_value": false}— the atom's actual value;"file_hint": {}— the mark itself, with nothing narrowing it.
Shape nodes
type is one of int, float, str, bool, date, time, none, list,
enum, struct.
id is Shape.option_id() — the identity a discriminator names, from
restrictions.md. It is written only where the slot holds two
or more options, because that is the only place anything reads it.
An id is unique within its discriminator's namespace, which is what a sender
needs: the compiler rejects a repeat among the struct options and a repeat
among all the others. It is not unique across the two — a dataclass and an enum
of one class name are admissible together, and both write that name. Index
options by position; read id only to fill a discriminator.
{"type": "int", "id": …, "min": 0, "exclusive_min": true,
"max": 10, "exclusive_max": true, "multiple_of": 5,
"choices": [0, 5, 10], "step": 5,
"slider": {"show_value": true}, "placeholder": "…", "extras": {…}}
{"type": "float", "id": …, "min": 0.0, "exclusive_min": true, "max": 1.0,
"exclusive_max": true, "choices": [0.5], "step": 0.01,
"slider": {"show_value": false}, "placeholder": "…", "extras": {…}}
{"type": "str", "id": …, "min_length": 1, "max_length": 20,
"pattern": "…", "pattern_message": "…", "choices": ["a", "b"],
"file_hint": {"extensions": [".pdf"], "min_size": 0,
"max_size": 1048576},
"is_password": true, "rows": 5, "placeholder": "…", "extras": {…}}
{"type": "date", "id": …, "min": "2024-01-01", "exclusive_min": true,
"max": "2024-12-31", "exclusive_max": true,
"choices": ["2024-06-01"], "placeholder": "…", "extras": {…}}
{"type": "time", "id": …, "min": "09:00:00", "exclusive_min": true,
"max": "18:00:00", "exclusive_max": true,
"choices": ["09:00:00"], "placeholder": "…", "extras": {…}}
{"type": "bool", "id": …, "extras": {…}}
{"type": "none", "id": "None", "extras": {…}}
{"type": "list", "id": …, "min_items": 0, "max_items": 10,
"item": [ <node>, … ], "extras": {…}}
{"type": "enum", "id": …, "ref": "Role", "extras": {…}}
{"type": "struct","id": …, "ref": "Cart"}
A number on a float node is written as a float, so that Min(0) and Min(0.0)
— equal atoms — cannot produce two documents. The exception is an integer with no
float of equal value, and there are two ways to be one. An integer outside the
float range (10**400) names no float at all, and the shape rejects it as an
invalid atom when it compiles — on min, on max and on step alike — so it
never reaches a document. An integer inside the range may still not be
representable, because above 2**53 the floats thin out and float() answers
with a neighbour instead of failing; that one arrives here, through an ordinary
Min or Max, and is written exactly as it is. Publishing the neighbour would
state a bound the schema does not hold: Min(2**53 + 1) written as
9007199254740992.0 invites a value the core then rejects as too small. JSON
has one number type, so in that case the distinction survives only until the
document is serialized.
A few names are deliberate:
min_length/max_lengthonstr,min_items/max_itemsonlist. The core already fixed that a bound means a length there and rejects an exclusive one. Calling itminwould invite a reader to render "minimum 3" over a text field.exclusive_minandexclusive_maxcannot appear on either.item, notitems. In JSON Schema an array underitemsmeans positional validation of a tuple. Here it is the set of options one element may take — the same word for two different things would be read wrong.ref, not$ref. In this project$marks reserved keys in data ($type,$value). Schema metadata does not borrow that convention.slideris an object, never a bare boolean:show_value: falsewould otherwise read as "no slider".extrasis last on every node, and omitted when empty. Its keys are already sorted and unique. AStructnode never has extras; the vocabulary does not admit atoms on a nested dataclass.
pattern is a Python re pattern applied with fullmatch. It is portable as a
string and not necessarily portable as a regex — a consumer targeting another
engine must check it, as pytypehintweb does for the browser.
Defaults
A default is written in the portable language of decode.md: a date
as "YYYY-MM-DD", a time as "HH:MM:SS", an enum member as its name, a list as
an array, a nested dataclass as an object.
Every certified default can be written this way. Compilation already proved that
its type, and the type of everything inside it, comes from the closed vocabulary,
so the encoding is total — there is no default the format has to omit, mark, or
fail on. A default_factory is not a special case: Field.default is the
certified product, and a portable tree has no aliasing to describe anyway.
Where two options of a slot share a portable spelling, the written value carries
the same wrapper decode consumes, so it names the option it belongs to. This is
a rule about a slot and not about a field, so it applies just as much to a list
element and to a field of a nested dataclass:
{"name": "limit", "default": {"$type": "int", "$value": 10},
"shape": [{"type": "int", "id": "int"}, {"type": "float", "id": "float"}]}
{"name": "when", "default": {"$type": "date", "$value": "2024-06-01"},
"shape": [{"type": "str", "id": "str"}, {"type": "date", "id": "date"}]}
{"name": "days", "default": [{"$type": "date", "$value": "2024-06-01"}],
"shape": [{"type": "list",
"item": [{"type": "str", "id": "str"}, {"type": "date", "id": "date"}]}]}
A dataclass default in a union of two or more dataclasses uses the inline $type
the core already uses, not the wrapper.
An impure recipe makes the written default a sample of one serving. defaults.md already asks for purity and does not watch for it; the format inherits that promise rather than restating it.
Reading the wire from the document
Everything a sender needs is derivable from the document, and the rule is short. To name an option unambiguously:
- a
structoption in a slot with two or morestructoptions — send the object with"$type": <name>inside it; - any other option in a slot where two or more options share a portable spelling
— send
{"$type": <id>, "$value": <portable>}; - otherwise — send the bare portable value.
Two options share a portable spelling when they are written the same way: int
and float both as a number, and str, date, time and any enum all as a
string. Two lists, or two structs, share theirs by definition.
The named spelling always works. The bare one is shorter and works wherever it is unambiguous — decode.md sets out exactly when.
What never appears
Implementation: the compiled re.Pattern behind pattern, the recipe behind a
default, the deferred-certification flag, the tuple form of extras, any method,
and the MISSING sentinel in any spelling — it is expressed by omitting default.
Python identity: class objects, __module__, __qualname__, __doc__ of a
dataclass or enum, filesystem paths, id(), memory addresses, hashes, and enum
member values.
repr() of anything. restrictions.md is explicit that an
option's identity is derived from the compiled shape and never from repr(), and
that holds for the whole format. Struct.__repr__ exists to debug, not to travel.
Interpretation: optional, required, nullable, flattening, traversal
indexes, or the "real option" of an X | None.
Provenance: the library version, timestamps, hostnames, the working directory.
Not a plan
to_dict() is not pytypehintweb's plan and must not grow into it. The plan
converts exclusive bounds to inclusive ones for <input>, folds int and
float together because JavaScript writes 3.0 as 3, carries every message
string a browser shows, and rejects combinations the core deliberately allows.
Each of those is a correct decision about a browser, and each would be wrong
for a CLI, for a queue consumer, or for another language. The core describes; the
wrapper decides.