json

Parse and serialize JSON, read and write JSON files, and work with nested maps by dotted path.

Oxigen
introduce json
FunctionSignatureReturns
parseparse(s)Oxigen value
stringifystringify(val)str
readread(path)Oxigen value
writewrite(path, val)None
get_inget_in(obj, path)value or None
set_inset_in(obj, path, val)the root map
del_indel_in(obj, path)the root map
has_key_inhas_key_in(obj, path)bool
set_whereset_where(arr, key, val, fields)array
mergemerge(a, b)map
deep_mergedeep_merge(a, b)map

Basic map operations are global builtins — no import needed: has(m, key), keys(m), values(m), insert(m, key, val), remove(m, key).

#Type mapping

JSONOxigen
nullNone
true / falseTrue / False
number (integer)Integer (Float past the range of a 64-bit int)
number (fractional or with an exponent)Float
"string"String
[array]Array
{object}Map

#Parsing and serializing

#parse(s)

Parse a JSON string. Objects become maps, arrays stay arrays, null becomes None.

Oxigen
introduce json

d := json.parse("\{\"a\": [1, 2], \"b\": null, \"c\": 1.5\}")
println(d)             // {a: [1, 2], b: None, c: 1.5}
println(type(d["c"]))  // FLOAT

Note the \{ escapes: a bare { inside an Oxigen string opens an interpolation, so a JSON literal written inline has to escape its braces. Most of the time the JSON comes from a file or a response body and the question does not arise.

Invalid JSON is an Error:

Oxigen
r := <type<Error> || <Value>>(json.parse("not json"))
println(is_error(r))   // True

The parser follows RFC 8259 strictly: numbers like 01, 1. or .5, unknown escapes like \q, and a \u escape that is not four hex digits are errors. A character outside the Basic Multilingual Plane written as a surrogate pair (\ud83d\ude00) decodes to that one character; a lone surrogate is an error.

Arrays and objects may nest at most 512 levels deep. Deeper input is an error rather than a crash, which matters when the JSON comes from a client: a request body of a few million [ would otherwise exhaust the stack.

#stringify(val)

Serialize any Oxigen value to a compact JSON string (no spaces, no trailing newline).

Oxigen
println(json.stringify({"name": "Oxigen", "version": 1}))
// {"name":"Oxigen","version":1}

Map key order is preserved as inserted. JSON keys are strings, so a number, boolean or char key is written as its text ({1: 2} becomes {"1":2}); any other key type is an error.

A structure that contains itself (a := [1]; a[0] = a) is an error rather than an endless recursion, and so is nesting deeper than 512 levels.

#read(path)

parse(io.read_file(path)) — read a file and parse it in one step.

Oxigen
config := json.read("config.json")
println(config.debug)

#write(path, val)

io.write_file(path, stringify(val)) — serialize and write in one step. Replaces the file if it exists.

Oxigen
json.write("config.json", {"debug": True, "level": 3})
println(json.read("config.json"))   // {debug: True, level: 3}

#Nested access by dotted path

The four *_in functions address nested structure with "a.b.c" strings, so you do not need to check each level yourself.

#get_in(obj, path)

The value at a dotted path, or None if any segment is missing or is not a map.

Oxigen
user := {"name": "Alice", "address": {"city": "NYC", "zip": "10001"}}

println(json.get_in(user, "address.city"))   // NYC
println(json.get_in(user, "address.nope"))   // None
println(json.get_in(user, "name.city"))      // None

None is also a legitimate stored value, so a None result means "missing or null". Use has_key_in when you need to tell those apart.

A path addresses maps only — it does not descend into arrays, so "items.0.name" will not work. For an array of records, find the record with array.find_where and carry on from there; to update or add one, use set_where.

#set_in(obj, path, val)

Set the value at a dotted path, creating intermediate maps as needed. Returns the root map.

Oxigen
user := {"name": "Alice", "address": {"city": "NYC"}}
updated := json.set_in(user, "address.state", "NY")
println(updated)
// {name: Alice, address: {city: NYC, state: NY}}

// Intermediate levels are created:
println(json.set_in({}, "a.b.c", 1))   // {a: {b: {c: 1}}}

Maps are mutable and shared. set_in modifies the map you passed and returns that same map — user above is changed too, not just updated. The same is true of del_in, merge, deep_merge, and the insert/remove builtins. If you need the original preserved, copy it first (for example json.parse(json.stringify(user))).

#del_in(obj, path)

Remove the key at a dotted path. Returns the root map. A path that does not exist is a no-op.

Oxigen
user := {"name": "Alice", "address": {"city": "NYC", "zip": "10001"}}
println(json.del_in(user, "address.zip"))
// {name: Alice, address: {city: NYC}}

#has_key_in(obj, path)

Does the dotted path exist? True even when the stored value is None.

Oxigen
println(json.has_key_in(user, "address.city"))   // True
println(json.has_key_in(user, "a.b"))            // False

#Arrays of records

JSON data is usually a list of records — {"information": [{...}, {...}]} — and a dotted path stops at the array. These two cover it: find the record with array.find_where, and update or add one with set_where.

Given example.json:

json
{"information": [
    {"person": "john", "info": {"age": 32, "job": "unemployed"}},
    {"person": "jane"},
    {"info": {"age": 28, "job": "engineer"}}
]}

#set_where(arr, key, val, fields)

Update the first record whose key equals val by deep-merging fields into it — or append a new record when there is none. This is the same rule as set_in, which also creates what is missing.

Oxigen
introduce json

data := json.read("example.json")

// john got a job — age survives, because the merge is recursive
data.information = json.set_where(data.information, "person", "john",
                                  {"info": {"job": "developer"}})

// ada is new — appended as {"person": "ada", "info": {...}}
data.information = json.set_where(data.information, "person", "ada",
                                  {"info": {"age": 36, "job": "mathematician"}})

// jane has no "info" at all — it gets created
data.information = json.set_where(data.information, "person", "jane",
                                  {"info": {"age": 41}})

json.write("example.json", data)
json
{"information": [
    {"person": "john", "info": {"age": 32, "job": "developer"}},
    {"person": "jane", "info": {"age": 41}},
    {"info": {"age": 28, "job": "engineer"}},
    {"person": "ada", "info": {"age": 36, "job": "mathematician"}}
]}

Notes:

  • Records without the key are skipped, not errors. The third entry above has no "person" field and is simply passed over — ragged data does not need a guard at every element.
  • Only the first match is updated.
  • It returns the array, edited in place (arrays are shared references, like maps). Assigning the result back is the clearer form and costs nothing.
  • json.write rewrites the whole file; there is no partial update.

#Editing a record directly

When you only want to change one field, find the record and assign — what find_where hands back is the record in the array, not a copy:

Oxigen
introduce json
introduce array

data := json.read("example.json")

john := array.find_where(data.information, "person", "john")
john.info.job = "developer"

json.write("example.json", data)     // data already sees the change

set_where is the version that also handles "…and add them if they are not there yet".

#Removing a record

Oxigen
data.information = array.remove_where(data.information, "person", "john")

#Merging

#merge(a, b)

Shallow merge: every key in b overwrites the same key in a.

Oxigen
defaults := {"port": 8080, "debug": False}
overrides := {"debug": True, "host": "0.0.0.0"}

println(json.merge(defaults, overrides))
// {port: 8080, debug: True, host: 0.0.0.0}

A nested map in b replaces the whole nested map in a — that is what "shallow" means.

#deep_merge(a, b)

Recursive merge: where both sides have a map under the same key, the maps are merged; anything else in b overwrites.

Oxigen
a := {"server": {"host": "localhost", "port": 8080}}
b := {"server": {"port": 9090}}

println(json.deep_merge(a, b))
// {server: {host: localhost, port: 9090}}

This is the one to use for layering config: defaults, then a file, then environment overrides.


#Worked example

Oxigen
introduce json
introduce net
introduce array

// Fetch a JSON list, keep the interesting fields, and save it.
resp := net.get("https://api.example.com/users")
users := json.parse(resp.body)

fun summary(u <map>) {
    {"id": u["id"], "city": json.get_in(u, "address.city")}
}

json.write("users.json", array.map(users, summary))

Layering configuration:

Oxigen
introduce json
introduce io
introduce os

defaults := {"port": 8000, "log": {"level": "info", "file": "app.log"}}

file_cfg := option {
    io.file_exists("app.json") -> { json.read("app.json") }
    { {} }
}

config := json.deep_merge(defaults, file_cfg)
config = json.set_in(config, "log.level", os.env_get("LOG_LEVEL")) when os.env_get("LOG_LEVEL") != None

println(json.get_in(config, "log.level"))

See also: toml — the same API for TOML, net and api for sending and receiving JSON over HTTP, io for raw file access.