# `json`

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

```jadpo
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:

```jadpo
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](values-and-types.md), [HTTP](http.md) and
[Configuration and time](configuration-and-time.md)).

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](magic-links.md)), 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](how-failures-reach-the-client.md)):

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

```jadpo
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:

```jadpo
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 1

test 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](how-failures-reach-the-client.md)),
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](file-batch-runs.md).

- **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](batches-work-over-many-items.md)).

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:

```jadpo
application {
    connections: {
        catalogues: {
            driver: Driver.disk
            path: "/var/lib/seeds/catalogues"
        }
    }
}
```

```jadpo
use seeds/types { PacketCode, SowingMonth }

/// One packet a partner's catalogue lists.
type PacketEntry = Input {
    code: PacketCode
    sow_in: List<SowingMonth>
}
```

```jadpo
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](logging.md)). 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.
