json
On this page
json reads and writes JSON. Given text, json.read reads one document
as one of your types, checking every rule of the type, and json.write
writes a value as compact text. Given a stored file, the same two names
read and write a large JSON list, a row at a time, as a batch.
| Operation | Gives | Fails | SQL form |
|---|---|---|---|
json.read(text, as: T) |
T, decoded and checked against every rule of T; text is a value built on Text, or on Bytes in UTF-8 |
malformed (not JSON, or an object naming a member twice), mismatch (a field missing, unknown or breaking a rule) |
none |
json.write(value) |
Text, compact; fields in declared order, sets and map keys in canonical order, so the same value always gives the same text |
none | |
json.read(file, items_at:, as:, each:, finished:, key:, max_unpacked_size:) |
BatchRun over a large JSON list in a stored File, items_at: naming where the list sits in the document, or left out when the document is the list; each item an Input of as:, handed to each: |
As csv.read |
none |
json.write(store, name:, from:, each:, finished:, key:) |
BatchRun writing a large JSON list, each item of from: turned into an Output by each: |
As csv.write |
none |
An order a supplier's system sends as JSON is read as the bakery's type
and written back. Reading checks every rule of the type, fills rush's
default and leaves note out. Writing gives the fields in declared
order, with no white space:
type SupplierCode = Text { max_length: 20}type LineCount = Int { min: 1 max: 1000}type LinePrice = Decimal { scale: 2 min: 0.00}type ImportedLine = Input { sku: SupplierCode count: LineCount price: LinePrice}type SupplierOrder = Input { reference: SupplierCode lines: List<ImportedLine> rush: Bool { default: false } note: SupplierCode { optional: true }}type SupplierMessage = Text { max_length: 100000}type OrderJson = Text {}type ImportUnreadable = Failure { kind: FailureKind.invalid_value}type ImportInvalid = Failure { kind: FailureKind.invalid_value}/// The order a supplier's message holds, checked against every rule of/// `SupplierOrder` in one step.function supplier_order( sent_order: SupplierMessage) fails ImportUnreadable, ImportInvalid -> SupplierOrder { return attempt json.read(sent_order, as: SupplierOrder) { malformed: ImportUnreadable mismatch: ImportInvalid }}/// The same order, as the one text `json.write` gives it.function order_json( sent_order: SupplierMessage) fails ImportUnreadable, ImportInvalid -> OrderJson { var order: SupplierOrder = attempt supplier_order(sent_order) return OrderJson(json.write(order))}
Text that isn't JSON fails malformed. JSON that isn't a
SupplierOrder (a line's count of 0, an unknown field, a missing
lines) fails mismatch, and the failure's report names the first
problem's path and rule, lines.0.count and min, never the value:
test "json reads and writes what the page says" { var order_text: OrderJson = attempt order_json( "{\"lines\": [{\"price\": \"3.5\", \"count\": 2, \"sku\": \"rye\"}], \"reference\": \"A-12\"}" ) assert order_text == "{\"reference\":\"A-12\",\"lines\":[{\"sku\":\"rye\",\"count\":2,\"price\":\"3.50\"}],\"rush\":false}" var order_back: OrderJson = attempt order_json( "{\"reference\":\"A-12\",\"lines\":[{\"sku\":\"rye\",\"count\":2,\"price\":\"3.50\"}],\"rush\":false}" ) assert order_back == order_text assert supplier_order("{\"reference\": ") fails ImportUnreadable assert supplier_order( "{\"reference\": \"A-12\", \"lines\": [{\"sku\": \"rye\", \"count\": 0, \"price\": \"3.50\"}]}" ) fails ImportInvalid assert supplier_order( "{\"reference\": \"A-12\", \"lines\": [], \"colour\": \"red\"}" ) fails ImportInvalid assert supplier_order("{\"reference\": \"A-12\"}") fails ImportInvalid}
One name for each direction
json.read given text reads one document, and given a file a large
list; json.write is the same. There is no json.decode.
The two forms can't be confused. Given text, json.read returns the
value; given a stored File with each:, it returns a run; using one
where the other is expected is a type error. json.write given a store
and named arguments is its file form, and given one value its text form.
What JSON can hold
as: takes any type an Input may hold, and reads it with an Input's
rules: unknown fields are refused, and optional and default are
applied. json.write takes any value an Output may hold. Both use the
wire forms that requests and responses use, Money's included
(Values and types, HTTP and
Configuration and time).
That is what the wire can carry: values built on the built-in and
standard types, enums your app or the standard library declares, and
Object, Input and Output types made of them, in lists, sets and
maps, nested to any depth.
Everything else is type.mismatch either way, and the error names the
type inside that has no JSON form: a failure, an event, an email, a
message, an entity, Unit, a secret (ApiToken, ServiceKey,
PostgresUrl, RedisUrl, SigningKey), a magic link's SignInCode or
SignInToken (Magic links), a
PluralCount, and the built-in types other pages define (Page,
File, IssuedToken, DecodeProblem). An entity reaches a client only
through an Output that picks what it sends, and a secret only through
a route's Output or its setting.
as: is a type written out, never T?. Keep a document that may be
absent as a Text?, and read it only when it is there.
Reading one document
json.read reads as a request's body is read, through the same codec:
one JSON document, with white space allowed around it. Text that isn't
one, or bytes that aren't UTF-8, are malformed. The first problem is
the first one the wire's reading finds, an object's fields in declared
order before its unknown ones. A problem with the whole document has an
empty path.
Writing one document
json.write gives a response body's text: compact UTF-8, with non-ASCII
characters as themselves and only ", \ and the characters below
U+0020 escaped. The same value gives byte-identical text on native and
on WebAssembly, which the gate checks against one fixed text.
Map keys
A map's key is its canonical text. json.write writes a key that isn't
text as text.from writes it, a date as 2026-10-25 and an amount of
money as GBP 10.50, and json.read reads it back as text.parse
does. A map keyed by a type with no single text form (an object, a
choice, a collection, an enum with data) is type.mismatch either way.
No JSON value of no type
There is no dynamic JSON value. Keep a payload whose shape isn't known
as Text, and pass it on.
What mismatch tells you
mismatch names the first problem the way HTTP's invalid_input does
(How failures reach the client):
- The field's path, with list positions as numbers
(
lines.2.quantity) and a map's entry as<key>, whether its key or its value broke the rule, at any depth (scores.<key>.<key>). An entry is never named by its key's text or position, since a key is a value. An unknown field's name stays in the path, as it does for a request. - The rule (
max), never the value.
The failure's report names both beside its caret: "breaks mismatch
(min on lines.0.count)".
A failure that says what was wrong: DecodeProblem
To tell the caller which field was wrong, declare a field
problem: DecodeProblem on the failure you map mismatch to, in its
client or its internal block. It receives the first problem: its
field, the path, with list positions as numbers and an unknown field's
name as a part, and its rule, never the value. A DecodeProblem is a
built-in type holding field: Text and rule: Text, named as HTTP's
input problems are.
- Only a field written
problem: DecodeProblemreceives it, by that name and that type exactly (notDecodeProblem?). A field of another name or type is an ordinary field the arm gives. - The arm gives every other field and never
problem; giving it isfailure.duplicate_context_field. invalid:fills it only when it coversmismatchalone.- Nothing else fills one. So mapping
malformed:, a construction's rule or arejectto such a failure isfailure.missing_context_field, its reason namingmismatch. - Only the runtime builds a
DecodeProblem.DecodeProblem { ... }istype.not_record, asIssuedToken { ... }andIssuedKey { ... }are, so a problem never holds a value a program wrote. - Its name is the language's. Like an issued token, it is a built-in object: the API record and the OpenAPI document list it under its own name, with its two fields required and no others.
A program reads its two fields, as the match on the outcome does here:
type ProductCode = Text { max_length: 20}type LineQuantity = Int { min: 1 max: 1000}type SupplierLine = Input { sku: ProductCode quantity: LineQuantity}type SupplierOrder = Input { reference: ProductCode lines: List<SupplierLine>}type SupplierMessage = Text { max_length: 100000}type ProblemPart = Text {}type OrderUnreadable = Failure { kind: FailureKind.invalid_value}type OrderInvalid = Failure { kind: FailureKind.invalid_value message: "The order breaks a rule." client: { problem: DecodeProblem }}/// The order a supplier's message holds; one that isn't a/// `SupplierOrder` fails `OrderInvalid` with its first problem.function supplier_order( sent_order: SupplierMessage) fails OrderUnreadable, OrderInvalid -> SupplierOrder { return attempt json.read(sent_order, as: SupplierOrder) { malformed: OrderUnreadable mismatch: OrderInvalid }}/// What is wrong with a message that isn't an order: the field's path/// and the rule.function order_problem(sent_order: SupplierMessage) -> List<ProblemPart> { return match supplier_order(sent_order) { success -> [] OrderUnreadable -> [] OrderInvalid(invalid) -> [ ProblemPart(invalid.problem.field), ProblemPart(invalid.problem.rule) ] }}
The first test reads the problem back: the field and the rule, even where
the value is a secret. The second lets the failure through, so
jadpo test shows its report, with the problem among the client fields
and nothing the document held:
test "the problem names the field and the rule, never the value" { assert order_problem( "{\"reference\": \"A-12\", \"lines\": [{\"sku\": \"rye\", \"quantity\": 2}, {\"sku\": \"spelt\", \"quantity\": 1}, {\"sku\": \"oat\", \"quantity\": 1001}]}" ) == ["lines.2.quantity", "max"] assert order_problem("{\"reference\": \"sk_live_51HxQ9eSECRETv4lue\", \"lines\": []}") == [ "reference", "max_length" ]}test "an order whose third line is over the maximum" { var order: SupplierOrder = attempt supplier_order( "{\"reference\": \"A-12\", \"lines\": [{\"sku\": \"rye\", \"quantity\": 2}, {\"sku\": \"spelt\", \"quantity\": 1}, {\"sku\": \"oat\", \"quantity\": 1001}]}" ) assert order.reference == "A-12"}
FAIL an order whose third line is over the maximum --> main.test.jadpo:12 failure: `OrderInvalid` from function supplier_order at: main.jadpo:43:20 |43 | return attempt json.read(sent_order, as: SupplierOrder) { | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ breaks `mismatch` (`max` on `lines.2.quantity`) reason: The value built here breaks `mismatch` (`max` on `lines.2.quantity`), so `attempt` raises the failure its block maps that rule to. code: order_invalid kind: FailureKind.invalid_value message: The order breaks a rule. client: { problem: DecodeProblem { field: "lines.2.quantity", rule: "max" } } path: test "an order whose third line is over the maximum" -> function supplier_order rule: test.unexpected_failure rerun: jadpo test main.test.jadpo:12 --seed 1test failed: 1 failed, 1 passed, 2 pure tests
A route whose action reads an order this way sends the problem in the
failure's details
(How failures reach the client),
under the names HTTP's input problems use. A problem declared in
internal stays on the server:
{ "error": { "code": "order_invalid", "message": "The order breaks a rule.", "request_id": "req_0b1e…", "details": { "problem": { "field": "lines.2.quantity", "rule": "max" } } }}
A large JSON list in a file
Given a stored File, json.read reads a large JSON list a row at a
time, as csv.read does. How its run goes is in
How a file's run is kept.
- The file form is the one given
each:. Ajson.readcall witheach:, or with anything else only the file form takes (items_at:,finished:,key:,max_unpacked_size:), is the file form, whose first argument is a storedFile. Given only text andas:, it reads one document in memory, as above. items_at:says where the list sits, as text written out in the call. It names the members that lead from the document's top object to its list, between dots, as a problem's path names fields ("data.items"). It names members only, so a list inside a list isn't reached, and a member whose name holds a dot can't be named. When the document is itself the list, asjson.writewrites one, leaveitems_at:out. Text that isn't written out, or that has an empty member name, istype.batch_row_invalid.- Each item is a row, an
Inputofas:. Unlike a CSV row, it may hold lists and objects, as a JSON Lines row may. Its row is its place in the list, counting from 1, and a problem's column is the field's path (lines.0.count), never the value. An item that isn't JSON is amalformedproblem. An item that isn't the row type has itsmismatchproblem's rule, and an item that isn't an object has the ruletype. - The document around the items is held to JSON's structure: the objects on the way to the list name each member once. Anything the reader can't read on from stops the run, with the items before it done (Batches).
A seed library reads the catalogue a partner library exports: a JSON
document whose packets sit at catalogue.packets, each packet's sowing
months a list, and which arrives gzipped:
application { connections: { catalogues: { driver: Driver.disk path: "/var/lib/seeds/catalogues" } }}
use seeds/types { PacketCode, SowingMonth }/// One packet a partner's catalogue lists.type PacketEntry = Input { code: PacketCode sow_in: List<SowingMonth>}
use seeds/entities { PartnerCatalogue, PartnerCatalogueNotFound }use seeds/types { PacketEntry }use seeds/actions { add_packet }/// Starts reading a kept catalogue's packets, once, unpacking it to at/// most 200 MB.action read_catalogue( catalogue_id: PartnerCatalogue.id) fails PartnerCatalogueNotFound -> PartnerCatalogue { var partner: PartnerCatalogue = attempt PartnerCatalogue.get(catalogue_id) var catalogue_run: BatchRun = json.read( partner.catalogue_file, items_at: "catalogue.packets", as: PacketEntry, each: listed_packet -> add_packet(listed_packet), key: partner.id, max_unpacked_size: 200mb ) return PartnerCatalogue.update(partner) { set: { catalogue_run: catalogue_run } }}
A document like this one, gzipped or not, adds two packets. The second
item's month is a problem, (2, "sow_in.1", "max"), and the rest carry
on:
{ "partner": "Hilltop Seed Swap", "catalogue": { "packets": [ {"code": "PEA-01", "sow_in": [3, 4]}, {"code": "BEAN-02", "sow_in": [5, 13]}, {"code": "KALE-03", "sow_in": [6]} ] }}
Not built yet: the runtime's hosts don't write the
failure.rejectedevent (Logging). Until they do, a failure's log is its report, with the problem among the fields it is declared in. The event, once written, holds aDecodeProblemas itself, not as a keyed hash, since a problem never holds a value. There is no XML format namespace yet.
Markdown for agents: /docs/json.md.Built from Jadpo main at a707efb.