json - JSON encode / decode
Enable with use json;. RFC 8259 JSON, mapped onto Jennifer’s value
model. Hand-rolled (no host encoding/json) so it works identically on
both binaries.
Surface
| Call | Returns | Notes |
|---|---|---|
json.encode(v) | string | Compact JSON for any encodable value (including a json.Value). |
json.encodePretty(v) | string | Same, 2-space indented; empty lists / maps stay []/{}. |
json.decode(s) | json.Value | Parse JSON text into an opaque handle (see below). |
Accessors over a decoded json.Value. Every one takes an optional
trailing JSON Pointer (RFC 6901) string, relative to the passed node
("" or omitted = the node itself):
| Call | Returns | Notes |
|---|---|---|
json.typeOf(v[, ptr]) | string | The node’s type: null bool int float string list map. |
json.get(v[, ptr]) | json.Value | The addressed sub-node, as a json.Value (walk stays opaque). |
json.has(v, ptr) | bool | Whether the pointer resolves to an existing node. |
json.keys(v[, ptr]) | list of string | Keys of the addressed map, in document order. |
json.length(v[, ptr]) | int | Element count of a list, or entry count of a map. |
json.asInt(v[, ptr]) | int | A number node with no fractional part. A float node errors. |
json.asFloat(v[, ptr]) | float | A number node; an integral one promotes (JSON has one number type). |
json.asString(v[, ptr]) | string | A string node. |
json.asBool(v[, ptr]) | bool | A true / false node. |
json.isNull(v[, ptr]) | bool | Whether the addressed node is JSON null. |
Write surface - every verb is non-mutating, returning a fresh
json.Value; the idiom is $v = json.set($v, ...):
| Call | Returns | Notes |
|---|---|---|
json.map() | json.Value | A fresh empty JSON map (object) - the start of a document. |
json.list() | json.Value | A fresh empty JSON list (array). |
json.set(v, ptr, val) | json.Value | Upsert a map key, or replace an in-range list index, at ptr. |
json.insert(v, ptr, val) | json.Value | Insert val into a list before index ptr (or - = at end). |
json.append(v, ptr, val) | json.Value | Push val onto the list addressed by ptr (sugar for insert at /.../-). |
json.remove(v, ptr) | json.Value | Drop the map key or list element at ptr. |
json.move(v, from, to) | json.Value | Relocate the subtree at from to to (read, remove, then set). |
Encoding
json.encode walks the value and writes its JSON image:
| Jennifer | JSON |
|---|---|
null | null |
bool | true / false |
int | integer number |
float | number (always with a . or exponent, so it decodes back as float) |
string | string (escaped) |
bytes | base64 string |
list of T | array |
map of string to V | object (in insertion order) |
| struct | object, keys in field-declaration order |
json.Value | the document it wraps (a decode / encode round-trip) |
Encode errors (positioned at the call): a map with a non-string key, a
task value, or a non-finite float (NaN / infinity have no JSON
image).
use io;
use json;
io.printf("%s\n", json.encode({"id": 1, "tags": ["a", "b"], "ok": true}));
# {"id":1,"tags":["a","b"],"ok":true}
Decoding
json.decode returns an opaque json.Value - a handle onto the
parsed tree. It is deliberately opaque: operators, [index], and
.field all reject it (with a hint to the accessors), so you never mix
a still-generic JSON node into typed code by accident. You reach inside
with the accessors, addressing nodes by JSON Pointer - the same paths
the (planned) write surface uses, so reads and writes are mirror images.
use io;
use json;
def doc as json.Value init json.decode("{\"x\": 7, \"y\": 8}");
io.printf("%d\n", json.asInt($doc, "/x") + json.asInt($doc, "/y")); # 15
Under the hood a JSON object is a map of string to V, an array a
list of V, and a number an int when it has no fractional or exponent
part (else a float) - which is why json.typeOf reports map / list
/ int / float, Jennifer’s own vocabulary, not “object” / “array” /
“number”. convert.typeOf($doc) is the generic "object";
convert.objectType($doc) is the specific "json.Value".
A def r as json.Value; with no initializer is a JSON null node
(json.isNull($r) is true).
A json.Value displays as its compact JSON, so echoing $doc at the
REPL, io.printf("%v", $doc), and convert.toString($doc) all show the
document (not an opaque <json.Value>); json.encodePretty($doc) is the
multi-line form.
JSON Pointer (RFC 6901)
A pointer is a slash-separated list of keys and list indices, addressing exactly one node:
""(or an omitted argument) is the node itself./user/namewalks map keyuser, then keyname./user/roles/0walks to a list and takes index0. Indices are0or[1-9][0-9]*- no leading zeros, no negative indices. The-end-marker is a write-only position (insert/append); reads reject it.- Escapes: a literal
/in a key is written~1, a literal~is~0(so keya/bis/a~1b, keym~nis/m~0n).
Pointers are relative to the node you pass, so json.get to a subtree
and short relative pointers compose:
def user as json.Value init json.get($doc, "/user");
io.printf("%s\n", json.asString($user, "/name")); # relative to $user
A missing key, an out-of-range or malformed index, or descending into a
scalar is a positioned error (catchable with try/catch); use
json.has to test a path without raising. There is no wildcard,
recursive-descent, or predicate syntax - a pointer names one node, which
is what lets the write verbs target it. (Query-style selection over many
nodes is a separate, deliberately unbuilt concern.)
To read the last element of a list, compute the index from
json.length - a plain pointer, no special syntax:
use convert;
def last as int init json.length($doc, "/qux") - 1;
io.printf("%d\n", json.asInt($doc, "/qux/" + convert.toString($last)));
Walking a nested document
use lists;
def doc as json.Value init json.decode(
"{\"user\": {\"name\": \"ada\", \"roles\": [\"admin\", \"dev\"]}}");
io.printf("name = %s\n", json.asString($doc, "/user/name"));
for (def i in lists.range(0, json.length($doc, "/user/roles"))) {
io.printf("role = %s\n", json.asString($doc, "/user/roles/" + convert.toString($i)));
}
json.typeOf and json.has let you branch on shape before extracting -
handy when a field may be absent or polymorphic:
if (json.has($doc, "/count") and json.typeOf($doc, "/count") == "int") {
def n as int init json.asInt($doc, "/count");
}
Building and editing
The write verbs mirror the reads: same JSON Pointer addressing, but they
return a new json.Value rather than mutating in place (like
lists/maps), so you rebind the result:
def v as json.Value init json.map(); # {}
$v = json.set($v, "/name", "ada"); # {"name":"ada"}
$v = json.set($v, "/tags", json.list()); # {"name":"ada","tags":[]}
$v = json.append($v, "/tags", "admin"); # ...,"tags":["admin"]
$v = json.insert($v, "/tags/0", "root"); # ...,"tags":["root","admin"]
$v = json.set($v, "/tags/1", "dev"); # replace index 1
$v = json.remove($v, "/name"); # drop a key
$v = json.move($v, "/tags", "/roles"); # rename a branch
The value you store may be a scalar, a Jennifer list / map / struct
(a struct normalizes to a map, bytes to a base64 string), or another
json.Value (its tree is spliced in). Since writes never mutate, an
earlier handle keeps its old value:
def a as json.Value init json.decode("{\"n\":1}");
def b as json.Value init json.set($a, "/n", 2);
# json.asInt($a, "/n") is 1; json.asInt($b, "/n") is 2
Writes are strict - no auto-vivification. set creates only the
final pointer segment; a missing intermediate is an error, as is a
set/insert on a scalar or a bare null root:
def v as json.Value; # a null node
# json.set($v, "/a", 1) -> error: cannot set a member of null
# json.set(json.map(), "/a/b", 1) -> error: no key "a" (build /a first)
So you build a nested document a level at a time, each parent created
before its child - the same discipline XML forces (you make an element
before you hang things on it), which is why the model ports cleanly. set
grows a map (new key) but only replaces an in-range list slot; grow a
list with append / insert. There is no deep-path creation and no
negative indexing - both deliberately, to keep pointers exactly RFC 6901
and unambiguous about list-vs-map intent.
The - end-of-array marker (RFC 6901’s “position after the last element”)
is honoured by insert / append only, where it means at the end -
so json.insert($v, "/xs/-", x) and json.append($v, "/xs", x) are the
same push. It is a write-position, not an element: set rejects /xs/-
(it never grows a list), and reads reject it too (there is no node there -
the last element is length - 1).
Heterogeneous data
A JSON array or object whose values are of mixed types has no single
Jennifer type - a list of T / map of string to V is homogeneous. The
opaque json.Value sidesteps that: each leaf is extracted one at a time
with a type-checked as*, so a document like
["Vienna", 2026, true, null, {...}, [1,2,3]] is walked freely, element
by element. This is the main practical win of the handle over decoding
straight into a typed collection.
No map-to-struct coercion
Jennifer does no coercion at binding boundaries (you write 5.0, not
5, for a float), and JSON decode is no exception: there is no automatic
map-to-struct landing. To put JSON in a typed struct, rebuild it
explicitly from the decoded handle - the schema is right there:
def struct Point { x as int, y as int };
def d as json.Value init json.decode("{\"x\": 7, \"y\": 8}");
def p as Point init Point{ x: json.asInt($d, "/x"), y: json.asInt($d, "/y") };
The encode direction has no such restriction - json.encode($p) serializes
a struct to an object directly, and json.encode($d) re-serializes the
decoded handle. See technical/rejected.md for
why the decode coercion was declined.
See also
- convert.md -
convert.typeOf("object") andconvert.objectType("json.Value") for identifying a handle. - encoding.md - hex / base64 and character-set codecs
(
jsonreuses base64 forbytes). - cheatsheet.md - every builtin at a glance.