Jadpo is experimental and not ready for production. These docs follow the compiler's main branch as of 30 Oct 2026. What's built · Install 0.1.0
jadpo
Reference: Standard library

Standard library

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:

example
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:

example
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: DecodeProblem receives it, by that name and that type exactly (not DecodeProblem?). 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 is failure.duplicate_context_field.
  • invalid: fills it only when it covers mismatch alone.
  • Nothing else fills one. So mapping malformed:, a construction's rule or a reject to such a failure is failure.missing_context_field, its reason naming mismatch.
  • Only the runtime builds a DecodeProblem. DecodeProblem { ... } is type.not_record, as IssuedToken { ... } and IssuedKey { ... } 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:

example
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:

example
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"}
text
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:

json
{  "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:. A json.read call with each:, 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 stored File. Given only text and as:, 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, as json.write writes one, leave items_at: out. Text that isn't written out, or that has an empty member name, is type.batch_row_invalid.
  • Each item is a row, an Input of as:. 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 a malformed problem. An item that isn't the row type has its mismatch problem's rule, and an item that isn't an object has the rule type.
  • 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:

example
application {    connections: {        catalogues: {            driver: Driver.disk            path: "/var/lib/seeds/catalogues"        }    }}
example
use seeds/types { PacketCode, SowingMonth }/// One packet a partner's catalogue lists.type PacketEntry = Input {    code: PacketCode    sow_in: List<SowingMonth>}
example
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:

json
{  "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.rejected event (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 a DecodeProblem as 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.