json Reads Into the Type the Caller Names, by the Database’s Rules; a Schema Is a Build-Time InputStatus: Accepted
Date: October 13, 2026 (#604)
Answers: what the official package json offers, where it lives, how a
text is read into a declared struct, what a JSON Schema does at build time,
Related:
ADR-338 D2, D3, D5 (formats are official packages, read and write);
ADR-299 D3, D15-D19 (a database row is the struct the block names, matched by name);
ADR-331 (@json::Name, attributes read through T::fields);
ADR-330 (a type passed where the parameter list holds one);
Part II 10.3 (generating code from a type’s shape), Part III 17.2, 17.3
What does json look like to a program, and how does a text become a
value of a type the program declared?
ADR-338 made JSON an official package and said it reads and writes. It did not say where the package’s source lives, what a read returns, which mistakes are found while the program is built and which while it runs, nor what a program does with JSON whose shape it does not know (an LSIF dump, a debugging aid).
| Language / tool | Its answer | What it costs there |
|---|---|---|
| Rust, serde | #[derive(Deserialize)] writes a decoder per type at compile time; serde_json::from_str::<T>; unknown fields ignored unless #[serde(deny_unknown_fields)]; serde_json::Value for the rest |
a derive on every type, a macro system; ignoring unknown fields hides a renamed field |
Go, encoding/json |
json.Unmarshal(data, &v) walks the struct by reflection at run time; tags json:"x"; unknown fields ignored unless Decoder.DisallowUnknownFields; interface{} / map[string]any for the rest |
reflection at every call; a missing field is silently the zero value |
Swift, Codable |
the compiler synthesizes init(from:) per type; JSONDecoder().decode(T.self, from:); a missing non-optional key throws, null only into an optional; unknown keys ignored |
a conformance on every type; no dynamic tree in the standard library |
All three generate or find the decoder from the type; only Swift is as strict
about missing fields and null as ADR-299 D17/D18 are; none is strict about
unknown fields by default.
T::fields is unrolled per type, and .name, .of(value) and
.attribute(X) work. .ty (ADR-329 D1, ADR-330 D5), a type as a
parameter, T::kind and match on a kind (ADR-330) are specified but not
built (field.ty is NK1180, a type parameter is a parse error), so
write[T], the schema and read[T] wait for the build issue of ADR-329 and
ADR-330 (#859). Tried with target/release/nikaia run: describe[T: Struct]
unrolls; a String? field then reaches the backend as a Rust Display
error rather than a Nikaia diagnostic..set, and nothing makes a T from field values (field.set(v, …) is
NK1180). A type passed as a parameter (ADR-330, schema(CreateUser)) is
not parsed yet. Both are what read[T] needs and are D3’s prerequisites.dsl block naming it (#485) do not, nor does ADR-338 D3’s
use that takes an official package in by itself.crates/rs2nika/facts) reads LSIF with a hand-written
reader (#579): the first program in this repository that needs JSON of a
shape it does not declare up front.packages/<name>/, and a change there raises only that package’s version.json is packages/json/, a package like any other with its own
nikaia.toml and version. A change there does not move the language’s
version; only the specification text that describes it does.
The first json exports functions and types only: no grammar export, and no
use that takes it in by itself. A program names it in [dependencies] by
path. ADR-338 D3 and the grammar export (#520, #485) are added later and
change no program that already compiles.
json::read[T](text) reads into the type the caller names; the decoder is the package’s grammar, specialised for T while the program is built.Changed by ADR-grammar-2 D1, D3, D10: the decoder is a
grammar entry that takes T, not a walk of T::fields.
struct Contact { name: String, email: String? }
let c = json::read[Contact](text) // a Contact, or a thrown json::Error
let s = json::write(c) // write[T], T inferred
A call, not a dsl block: the text arrives while the program runs, and there
is nothing to check at the call but the type. json::read[T] forwards to the
entry Json::read[T] of the package’s grammar, which the compiler emits once
per type a call names and which builds the T itself (ADR-grammar-2); no
reflection happens at run time. A failure throws json::Error, which carries
the form of a grammar’s ParseError (ADR-001 D2, Part III Appendix C): the
line and the column, and the stack of what was being read there. The path of
the value ($.contacts[3].email) is that stack as JSON names it, not a second
report beside it: every reader of a text, grammar or JSON, fails in one form.
null in a T?.A member matches the field of its name (or the name @json::Name gives,
ADR-331), never by position. A member given twice is an error. A field with
no member takes its default if it has one (Part I, ADR-331 D5), is null if
its type is a T?, and is otherwise an error. A member that is null goes
into a T?; for a field without ? it takes the field’s default if it has
one, and is otherwise an error. A member with no field is skipped (D8).
Why a missing member is null in a T?, where ADR-299 D17 refuses it: JSON
writers commonly leave out what is null, and a field optional in the type
says the text may lack it. A field without ? and without a default is one
the type requires; a text without it is refused rather than given a zero
value. A default is the type’s own statement of what a missing value means,
so it applies to a read as it does to a literal.
Why an explicit null takes the default of a field without ?: a read runs
when the program runs, where a refusal is expensive and nobody can see why it
was needed if the type gave a value for exactly this case. An explicit null
says something other than a missing member - the writer knows the field and
has no value - but for a field that has a default, both mean “no value here”,
and the default is the type’s answer to that. Without a default, null has no
value to become and stays an error. Serde and Pydantic refuse null for a
non-optional field even when it has a default (from memory, not checked
here); this record is deliberately more lenient at that one point.
bool, the integer and float types, String, a T? of one, a Vec[T] of
one, a Map[String, T], a declared struct (an object) or enum. Any other field type is refused at the call with
NK1250, naming the field and the path to it.
T is checked against it.comptime SCHEMA = asset("contact.schema.json")
let c = json::read[Contact](text; schema: SCHEMA)
As ADR-299 D3: the schema is a file in the repository, read through asset.
The compiler checks T against it by D4’s rules - a required property T
lacks, a field the schema does not have (unless additionalProperties
allows it), a type that does not match, a T field for a property that may be
null - and refuses the call with NK1251, naming the property and the
field.
Not decided here: run-time checks of the schema’s constraints
(pattern, minLength, minimum, enum …). Nothing in this repository
needs them yet, and they raise questions this record cannot settle without a
user: whether they cost every read, whether a value’s type should carry them
(a refinement type) instead of the call, and which drafts’ regular
expressions apply.
read[T] is the preferred way (owner, October 26, 2026).Changed by ADR-grammar-2 D10: parse is the grammar’s
second entry, beside read[T].
json::Value holds any JSON and json::parse(text) reads into it. A number is
kept as its text (a view of it) and converted when it is asked for as a type,
because many writers put 12.0 where an integer is meant, so choosing integer
or float while reading would be wrong for them. The members of an object keep
their order. Malformed JSON is json::Error, with line, column and path.
The static form (D3) is what a program uses when it knows the shape. Whether the dynamic form is needed at all is a question for research: it is kept because it was already begun, not because a program is known to need it. It gets no warning now. A deprecation attribute on it is considered once the static form is shown to hold for every use that calls the dynamic one today (the facts tool, #579).
json::write[T](value) writes the text (ADR-338 D5).Fields in declared order, the names D4 reads, a T? that is null written as
null.
json::read[T] skips a member of an object for which T has no field; its
value must still be well-formed JSON. This departs from ADR-299 D17 on purpose.
Why: in practice an unknown member is a protocol extension - a server adds a
field, a newer client writes one - and a reader that refuses it breaks when
the other side grows, without any change in the program. A database result
has exactly the columns the query names, so a column without a field there is
the program’s own mistake and D17 refuses it; JSON from outside grows without
the program’s doing, so the same refusal would turn every extension into an
outage. The cost D17 names remains: a member renamed on the other side
becomes a missing one. D4 still catches that where the field is required (no
?, no default); only an optional or defaulted field silently stays empty.
How others do it (from the projects’ documentation as remembered; not re-checked against current releases for this record):
| Language / tool | Unknown members by default | Strict on request |
|---|---|---|
| Rust, serde | ignored | #[serde(deny_unknown_fields)] |
Go, encoding/json |
ignored | Decoder.DisallowUnknownFields() |
Swift, Codable |
ignored | none built in |
| Java, Jackson | refused (FAIL_ON_UNKNOWN_PROPERTIES is on) |
the default; commonly turned off, or @JsonIgnoreProperties(ignoreUnknown = true) |
| Kotlin, kotlinx.serialization | refused (ignoreUnknownKeys = false) |
the default; Json { ignoreUnknownKeys = true } relaxes it |
| Python, Pydantic | ignored (extra = "ignore") |
extra = "forbid" |
The rule follows the robustness principle, “be conservative in what you do,
be liberal in what you accept from others” (RFC 761 2.10, RFC 1122 1.2.2).
RFC 9413 criticises it: tolerating deviations lets them become de-facto
protocol and hides defects. That criticism is about accepting malformed
input; a well-formed member the reader does not know is an extension point,
which RFC 9413 wants protocols to keep usable, so it does not argue against
D8. Malformed JSON, a member given twice and null for a field that is not a
T? and has no default stay errors.
Not decided here: a field of type Map[String, json::Value] marked
@json::Rest that collects the skipped members - it needs the dynamic form (D6a). A strict mode to switch on (per call or per
type, like serde’s or Go’s): no program needs one yet and nothing has asked
the question; it is open, not rejected.
dsl block (dsl json::Read -> T { … } eod): a block checks a text
written in the program; here the text arrives at run time, so there is
nothing for a block to hold. The typed read is a grammar entry that takes
the type (ADR-grammar-2), not a block.@json::Open to skip them (Jackson’s
and Kotlin’s default): the common case would need the attribute on every
type that reads outside JSON, and the type that forgot it breaks only when
the other side grows; Jackson’s default is the one most often turned off.T? (ADR-299 D17 unchanged):
refuses the JSON most writers produce, which leaves null out.0 or "".null only into a T?, otherwise an error (serde, Pydantic): stops a
running program over a member the type already says what to do with.T::fields needs a way to build a value (a reflected field that can be
given, or a constructor from fields), and ADR-330’s type parameter needs
parsing, before read[T] can be written in Nikaia.packages/.