std.json

import std.json

json.encode(value)                  # value -> string
json.decode(text)                   # string -> value
json.decode(text, decimal: true)  # ...with every non-integer as an exact decimal
json.read(path)                     # a file -> value (takes the same options)
json.write(path, value)

The globals json_encode and json_decode do the same encoding and decoding without an import; this module adds the file forms.

Deterministic output

Maps encode with sorted keys. The same value always produces the same bytes, so encoded JSON can be compared, hashed, checked into a repository, or asserted on in a test without normalizing it first.

Type mapping

EckoJSON
null, bool, stringas-is
int, floatnumber
decimalnumber, written exactly: 12.50m is 12.50
bytesbase64 string
list, maparray, object
structobject with its fields, plus "__type__" naming the variant
a function, task, channel, stream, module or rangerefused: { kind: "bug" }

A value with no JSON form is refused rather than written as its printed form, so a function that ends up in a payload fails at the json.encode, not at whoever reads "fn(x)" later. http.json and an HTTP request's json: body refuse it the same way.

Exact decimals: decimal: true

A decimal is written exactly, but by default a number decodes as an int or a float, so a money field that goes out as 19.99 comes back as a float. Pass decimal: true and every number that is not an integer decodes as an exact decimal, read from its own text:

import std.json

order = { total: 19.99m, fee: 0.30m }
back = json.decode(json.encode(order), decimal: true)
print(back == order)  # true
print(back.fee)  # 0.30 - the scale survives too

Integers stay int. A number with more than 28 significant digits does not fit a decimal and is a parse error rather than a rounded value. Use the option for anything carrying money; leave it off for coordinates, ratios and other data you will do float arithmetic with, since a decimal and a float never mix.

Two edges worth knowing, both because JSON has no such type:

  • bytes encodes as base64 and decodes as a string. Decode it back explicitly with encoding.base64_decode.
  • A struct decodes as a plain map, "__type__" key included. Rebuild the record from its fields if you need the type back.

Errors

Malformed input raises { kind: "parse", format: "json" }, and json.read adds path. Always catch around anything decoded from outside:

try {
    data = json.decode(body)
} catch (e) {
    match get(e, "kind") { "parse" => bad_request() _ => error(e) }
}

Decoded data is uncertain in shape

A decoded value is a map whose keys you hope are there. data.user.email raises if any level is missing. Two better tools:

  • get for expected absence: get(data, "email").
  • match, which tests rather than accesses, so a missing key is a non-match rather than an error.

For validating a whole payload with all errors collected, use the validate package.

Typed model output

ai[json<List<Int>>] asks a model for a shaped JSON value and coerces the reply through the schema, which is usually better than decoding a string yourself - see typed output.