Skip to content

JSON

3 min read

JSON lets your program exchange structured data as text. Mark your own data type with @json for convenient conversion, or use JsonValue when the data's structure is not known in advance.

Import: import system.json; (also loaded when a type has @json)

Mark a class @json to serialize and parse it. Works on value structs too.

import system;
import system.json;

@json
class User {
    name: string;
    age: int;
}

fun main() {
    let u = User("Ada", 36);
    let text = Json.serialize(u);
    let back = Json.deserialize<User>(text).unwrap_or(u);
    System.println(back.name);
}
API Role
Json.serialize(x) Compact JSON text (@json types, List/Set/Map<string, V>/SortedMap<string, V>, arrays, and JsonValue)
Json.serialize_pretty(value, indent) Pretty-printed JsonValue (indent 0 is compact). For @json types use serialize_pretty(x.to_json(), indent)
Json.deserialize<T>(text) Result<T, ParseError> — typed T, or JsonValue when the shape is unknown
Json.from_value<T>(value) Already-parsed JsonValue into T (from_value<JsonValue> is identity)

Fields may be primitives, string, arrays, other @json types, tuples (JSON arrays), List/Set/Map<string, V>/SortedMap<string, V>, JsonValue, and Option<T> of supported types. JSON object keys are strings, so maps must be Map<string, V>. @property_name("key") renames a JSON key. Skip a field with the ignore attribute. Unions serialize with a "type" tag.

Every field type must be one @json already knows. object is not: Json.serialize on a Map<string, object> is rejected. Reach for JsonValue instead — it carries its own shape and is passed through untouched.

Json.serialize(data) infers T from data (including an annotated let data: Map<string, string> = …). deserialize still needs an explicit type argument because the JSON text does not name a Dream type.

Unknown payloads (JsonValue)

When you do not have a class for the JSON (an API body, a config blob, mixed values), parse to JsonValue. That is the string-keyed dict of mixed values — not Map<string, object>, which cannot be serialized.

Parse

switch (Json.deserialize<JsonValue>(text)) {
    Ok(v) => walk(v),
    Err(e) => System.println(e.message()),
}

// HTTP already returns a tree:
switch (r.json()) {
    Ok(v) => walk(v),
    Err(e) => System.println(e.message()),
}

Read one field

v.get(key) returns the property as Option<JsonValue>: None when the key is missing or v is not an object. Chain a typed reader through and_then to decode it, and unwrap_or to supply a fallback; a wrong JSON type is also None. v[key] returns the property directly and panics when it is missing, so use it only when the shape is already known. v.contains(key) tests presence.

let name = v.get("name").and_then((item: JsonValue) => item.as_string()).unwrap_or("");
let n = v.get("count").and_then((item: JsonValue) => item.as_int()).unwrap_or(0);
if v.contains("error") {
    System.println(v.get("error").and_then((item: JsonValue) => item.as_string()).unwrap_or(""));
}

is_* / kind() are an integer tag check. as_bool / as_int / as_double wrap a scalar in Option. as_string clones the string. as_map / as_array return the existing container.

Walk with kind()

switch (v.kind()) {
    case JsonKind.Object:
        let i = 0;
        while i < v.length { // insertion order
        let key = v.key_at(i).unwrap_or("");
        let child = v.value_at(i).unwrap_or(JsonValue.null());
        System.println(key + " = " + Json.serialize(child));
        i = i + 1;
    }
    case JsonKind.Array:
        switch (v.as_array()) {
        Some(items) => {
            for (let item in items) {
                System.println(Json.serialize(item));
            }
        },
        None => {}
    }
    default:
}

let dict: Map<string, JsonValue> = v.as_map().unwrap_or(Map<string, JsonValue>());

Json.serialize(dict) round-trips that map. Iterating as_map() is unordered; key_at / value_at follow JSON key order.

Nested values

switch (v.get("meta")) {
    Some(meta) => {
        System.println(meta.get("id").and_then((item: JsonValue) => item.as_int()).unwrap_or(0));
    },
    None => {}
}

Known shape with one open field

@json
class Envelope {
    public id: int;
    public payload: JsonValue; // kept verbatim through serialize/deserialize
}

let mixed: Map<string, JsonValue> = {
    "n": JsonValue.from_int(42), "s": JsonValue.from_string("text"),
};
System.println(Json.serialize(mixed)); // {"n":42,"s":"text"}

Accessors

Kind Test Read Build
object {...} kind() / is_object get_str / get_int / get / has / key_at / as_map JsonValue.dict(), then set
array [...] kind() / is_array at(index) / as_array / .length JsonValue.array(), then push
string / number / bool / null kind() / is_null str_or / int_or / as_string / as_int / as_double / as_bool from_string / from_int / number / boolean / none

How @json works

@json is an ordinary Dream source generator, json_derive in system.json. Importing system.json registers it, so it works the same in single files, packages, dependencies, dream test, and wasm builds. It runs only when the program has a @json type or calls Json.serialize, Json.deserialize, or Json.from_value. It is @incremental, so unchanged programs replay its cached output. The generated code is written to generated/<entry>/system/json/derive/json_derive/json.dream; to step through the derive itself, see Debugging a generator.