Standard library Dodo 0.1.4

JSON encoding and decoding

Decode JSON into checked structs, derive encoding, and inspect borrowed JSON without an allocator.

On this page

Import std/encoding/json and add @derive(Json) to a struct to encode and decode its fields. JSON types are checked during decoding, and ordinary Dodo field access remains statically typed. The module is portable and needs no allocator, operating system, or runtime reflection.

JSON has six value kinds: null, boolean, number, string, array, and object. An object associates string names with values; an array stores values in order. Choose a typed struct when you know the expected fields, or a borrowed Value when you need to inspect a document whose shape varies.

Goal Starting point
Decode a known object shape @derive(Json) and json.decode::<T>(bytes)
Inspect a document or primitive root json.parse(bytes) and Value accessors
Parse wide objects with a key index json.parse_indexed(bytes, &mut scratch)
Accept escaped external strings A json.String field and its decode method
Encode your own struct json.to_slice(&value, &mut storage)
Generate values one token at a time json.Encoder.new(&mut writer)
Read a complete bounded document from I/O json.parse_reader or decode_reader
Read several whitespace-separated documents json.Decoder over existing bytes

Decoding generally returns views into the input rather than copying its text. Keep the input alive until all decoded views are finished. Encoding needs a destination whose capacity you choose. Byte I/O, UTF-8 text, and Results provide the underlying concepts.

Quickstart

Save this as json_start.dodo:

package json_start
import "std/encoding/json"
import "std/bytes"

@derive(Json)
struct Person {
    name: json.String
    age: u8
    nickname: Option<json.String>
}

fn round_trip() -> void!json.Error {
    source := b"{\"name\":\"Ada\",\"age\":36,\"nickname\":null}"
    person := json.decode::<Person>(source)?
    assert_eq(person.age, 36u8)

    storage := [0u8; 128]
    written := json.to_slice(&person, &mut storage)?
    assert(bytes.equal(&storage[..written], source))
    return ok()
}

fn main() -> i32 {
    match round_trip() {
        ok() => {
            return 0
        },
        err(_) => {
            return 1
        },
    }
}
dodo run json_start.dodo

Expected output: none; exit 0 confirms the typed round trip. Exit 1 means decoding or encoding failed. Increase output capacity when handling BufferFull; reject malformed input or inspect its error before retrying. The complete examples/json.dodo also constructs a struct with json.String.from_str("Grace").

The steps are: declare the expected fields, decode and validate a complete document, use ordinary typed fields, then encode into a separate destination. The output matches this particular input because its field order and compact representation match the derived encoder. In general, decode/encode preserves the JSON value, not original whitespace or escape spellings.

Structs and field names

@derive(Json) generates encode_json and decode_json methods. It supports booleans, signed and unsigned integers, finite floating-point values, &str, json.String, nested derived structs, Option<T>, and fixed arrays with literal lengths of at most 4096 elements. Unsupported field types produce a compile-time diagnostic. User methods can implement the same structural contract for custom representations.

@derive(Json)
@json_deny_unknown
struct Account {
    @json_name("displayName")
    display_name: json.String
    visits: u32
    active: bool
    nickname: Option<json.String>
}

Field names match exactly, including case, after JSON string escapes are decoded. @json_name("displayName") applies the same name in both directions. Unknown members are ignored by default; @json_deny_unknown rejects them. Required fields must be present and have the declared type. Option<T> accepts a missing member or JSON null as none; some(value) decodes the underlying type. Encoding none emits null. Fixed arrays require exactly their declared element count. Derived array decoders advance through elements sequentially, with no repeated index lookup or separate length pass. Primitive arrays use a generated loop. Derived struct decoders traverse the object once, dispatching decoded field names through a generated hash decision tree and tracking which members were present. They retain field views in storage proportional to the schema size, then convert them in declaration order. Unknown-field checks precede conversion, and borrowed fields keep their dependency on the input. Hash collisions receive full name comparisons. Document validation, including duplicate detection, is separate. Decoding does not coerce strings into numbers, numbers into booleans, or null into a required field’s zero value.

Integer conversion checks the destination range and preserves all 64 bits. An integer field rejects fractions and exponent notation, even if their mathematical value is integral. Floating-point conversion rejects overflow; encoding rejects NaN and infinities. Generic derived structs, field defaults, flattening, and automatic enum derivation are not currently supported.

Entry points and output

The generic encode and decode functions accept types implementing the JSON method contract: derived structs, json.Value, json.String, and custom codec types. Bare primitive roots such as u64 and bool do not implement those methods. Decode them through json.parse and a checked Value accessor; encode them with the corresponding Encoder method. Primitive struct fields work directly through derivation.

Operation API
Decode a struct from bytes json.decode<T>(input) -> T!json.Error
Decode a struct from text json.decode_str<T>(input) -> T!json.Error
Decode an inspected value json.from_value<T>(value) -> T!json.Error
Inspect JSON dynamically json.parse(input) -> json.Value!json.Error
Inspect a language string json.parse_str(input) -> json.Value!json.Error
Inspect JSON using a scratch key index json.parse_indexed(input, &mut scratch) -> json.Value!json.Error
Inspect text using a scratch key index json.parse_str_indexed(input, &mut scratch) -> json.Value!json.Error
Encode to caller-owned bytes json.to_slice(&value, &mut storage) -> usize!json.Error
Encode through a byte writer json.encode(&value, &mut writer) -> usize!json.Error
Encode readable output json.encode_pretty(&value, &mut writer) -> usize!json.Error
Encode readable output into bytes json.to_slice_pretty(&value, &mut storage) -> usize!json.Error
Validate without retaining a view json.validate(input) -> void!json.Error
Read a document into caller-owned bytes json.parse_reader(&mut reader, &mut storage) -> json.Value!json.Error
Decode a document from a reader json.decode_reader<T, R>(&mut reader, &mut storage) -> T!json.Error
Remove insignificant whitespace json.compact(input, &mut writer) -> usize!json.Error
Format JSON text for readability json.pretty(input, &mut writer) -> usize!json.Error

Writer output uses the portable byte I/O structural contract. A fixed buffer keeps capacity explicit; an existing growable writer can supply separately managed storage. Output functions report the number of bytes written on success. Failure can leave an output prefix, so do not treat partially written JSON as a complete document. There is no hidden allocation or automatic retry.

Reader helpers read through EOF into the supplied storage. When storage fills, the reader consumes one extra byte to distinguish an exact fit from exhaustion. An oversized document returns BufferFull; returned views borrow the storage, not the reader. These helpers handle a complete document, rather than a sequence of independent JSON values on a live stream.

For multiple values already held in a buffer, json.Decoder.new(input) or json.Decoder.from_str(input) creates a cursor. next() returns Option<json.Value>!json.Error: some(value) for a document and none at EOF. Documents must be separated by JSON whitespace, so newline-delimited JSON works without a special line parser. Use or drop each returned view before advancing the decoder. position() reports the byte cursor; a malformed document leaves the cursor unchanged and subsequent calls return the same error.

Indexed parsing

For wide objects, parse_indexed and parse_str_indexed use caller-provided &mut[usize] scratch storage to avoid repeatedly scanning earlier members. They enforce the same JSON syntax, decoded-name uniqueness, and nesting limit as parse. Hash collisions are resolved by comparing complete decoded names.

package indexed_json
import "std/encoding/json"

fn main() {
    scratch := [0usize; 128 * json.KEY_INDEX_WORDS]
    value := json.parse_indexed(b"{\"name\":\"Ada\",\"age\":36}", &mut scratch)!
    // Scratch can be reused while earlier input-backed values remain live.
    next := json.parse_str_indexed("{\"ok\":true}", &mut scratch)!
    age := value.require("age")!
    ok := next.require("ok")!
    assert_eq(age.as_u64()!, 36u64)
    assert(ok.as_bool()!)
}

Each live object member requires json.KEY_INDEX_WORDS (five) words. Capacity is scratch.len / json.KEY_INDEX_WORDS; any remaining words are unused. Entries belong to open objects and are released when those objects close, so nested objects share the same storage and successive objects in an array reuse it. Size scratch for the largest combined number of members already encountered in all currently open objects, including the member whose value is being parsed. A flat 1,024-field object needs 5,120 words (40 KiB on a 64-bit target).

Insufficient capacity returns BufferFull at the new key’s opening quote; it does not silently fall back to rescanning. Empty scratch accepts documents without object members. Each call resets the index, including after a failed parse, and returned values borrow only the input. Use json.from_value::<T> to decode an indexed result with an existing derived or custom codec.

Index setup takes time proportional to scratch capacity. With well-distributed hashes, validation takes expected linear time in input size plus that setup; deliberately colliding keys can still cause quadratic comparisons. The original parse entry points keep their constant-extra-memory duplicate detection.

Borrowed values and strings

Input ownership

Value and decoded json.String retain checked shared borrows of their input. Keep the input alive and unchanged while using them. A decoded struct containing either view has the same lifetime dependency. Decoding an object does not build an owned heap tree; array/object navigation scans the validated source. Repeated lookups can therefore repeat work. The parse and parse_str entry points use constant extra storage for duplicate-name validation and compare earlier members; their work grows quadratically with the number of object members. Use indexed parsing to supply scratch storage for this check. Apply application input limits to large external documents.

json.String represents a complete JSON string, including escaped content. equals("text") compares its decoded contents; equal(&other) compares two JSON strings. decoded_len() gives the required UTF-8 output byte count. decode(&mut storage) unescapes into caller-owned storage and returns a checked &str borrowing that destination. String.from_str(value) wraps an existing language string for encoding without copying it.

as_str() returns a borrowed language string only when no JSON escapes need decoding. It returns EscapedString for escaped content, rather than returning raw escape sequences as text. The same restriction applies when deriving a field of type &str; use json.String for arbitrary external JSON strings. raw() exposes the representation’s content without surrounding quotes, and is_escaped() identifies encoded string content. into_str() consumes the string wrapper while transferring its input borrow; it likewise rejects escapes.

Decode an escaped string into your own storage

The input below represents a JSON string whose final letter is written as a Unicode escape. Its decoded value is café. Four Unicode scalars need five UTF-8 bytes, so capacity should be based on decoded_len, not character count.

package json_string_bytes
import "std/encoding/json"

fn example() -> void!json.Error {
    root := json.parse(b"\"caf\\u00e9\"")?
    value := root.as_string()?
    assert(value.is_escaped())
    assert(value.equals("café"))
    assert_eq(value.decoded_len(), 5usize)
    storage := [0u8; 16]
    decoded := value.decode(&mut storage)?
    assert_eq(decoded, "café")
    return ok()
}

fn main() -> i32 {
    match example() {
        ok() => { return 0 },
        err(_) => { return 1 },
    }
}

Save as json_string_bytes.dodo and run dodo run json_string_bytes.dodo. It exits with zero and prints nothing. The final decoded view borrows storage; it does not depend on the original JSON bytes. Calling as_str() on value instead would return EscapedString because the representation contains an escape sequence.

Inspecting JSON without a struct

Value.kind() identifies the JSON kind. Fallible conversions as_bool(), as_i64(), as_u64(), as_f64(), as_string(), and as_str() check it; is_null() recognizes null explicitly. get(name) looks up an optional object member, while require(name) reports a missing field. at(index) selects an array element. len() reports an array or object’s member count. Array and object iterators expose members without allocating a collection.

get(name) returns Option<Value> and returns none for a missing member or a non-object. at(index) likewise returns none for a missing index or a non-array. Use require(name), require_object(), array(), or object() when you need a type error rather than an absent optional value.

Iterate an array

array() checks the root kind once and creates a cursor. next() returns a borrowed child or none at the end; its input is already validated, so advancing the iterator does not return a parsing Result. Each scalar conversion still checks the child’s type and range.

package json_array_values
import "std/encoding/json"

fn example() -> void!json.Error {
    root := json.parse(b"[10,20,30]")?
    values := root.array()?
    total := 0i64
    for {
        match values.next() {
            some(value) => { total += value.as_i64()? },
            none => { break },
        }
    }
    assert_eq(total, 60i64)
    return ok()
}

fn main() -> i32 {
    match example() {
        ok() => { return 0 },
        err(_) => { return 1 },
    }
}

Save as json_array_values.dodo and run dodo run json_array_values.dodo. Success exits with zero. object()?.next() uses the same pattern and returns Entry { key: json.String, value: json.Value }. Consume or finish using the current child before advancing either iterator again.

pointer("/users/0/name") navigates an RFC 6901 JSON Pointer. An empty pointer selects the current value; ~0 represents a literal ~ and ~1 represents a literal / in a member name. Array indexes use decimal digits without leading zeros. Missing members/indexes return MissingField; malformed paths return Syntax, and traversal through a scalar returns TypeMismatch. URI fragment pointers beginning with # are not supported.

The array token - denotes the nonexistent append position and returns MissingField; it never mutates the document.

raw() returns the validated source representation and position() reports its byte offset in the original document. Dynamic inspection is useful for unknown schemas; derived structs are the shorter path when fields are known. into_raw(), into_string(), and into_str() consume a value view and transfer its input borrow into the result, which is useful when returning extracted data from a helper. into_str() has the same escaped-string restriction as as_str().

Validation and errors

Parsing consumes one complete JSON document. It rejects trailing content, comments, trailing commas, duplicate object names, leading-zero numbers, malformed UTF-8, unescaped control characters, invalid Unicode escapes, and unpaired surrogate halves. Duplicate names are rejected even when one uses escapes, such as "age" and "\u0061ge". Nested containers have a maximum depth of 128. String equality uses decoded Unicode content without normalization or case folding.

Errors distinguish syntax failures, unexpected JSON kinds, missing/duplicate/ unknown fields, numeric range failures, capacity exhaustion, I/O errors, depth limits, trailing data, and escaped-string borrowing. Error.position is a byte offset in the input for parsing and conversion errors; writer failures report the number of output bytes already emitted. Handle errors with match, propagate them with ?, or use ! only when failure should terminate the program. See Results.

Error kind Typical cause Response
Syntax, TrailingData, Depth Malformed input, extra data, or excessive nesting Reject the document and inspect the byte position.
TypeMismatch, NumberRange A value does not fit the requested Dodo field type Correct the schema or reject that input.
MissingField, DuplicateField, UnknownField An object violates the required field rules Correct the document or choose the intended unknown-field policy.
BufferFull Caller storage cannot hold the input, decoded text, or output Increase the relevant bound or reject the oversized value.
EscapedString Borrowing &str would require unescaping Use json.String.decode into caller-owned storage.
Io The underlying reader or writer failed Treat an emitted prefix as incomplete and inspect Error metadata.

Custom codecs and token-by-token encoding

Custom encode_json<W> methods receive &mut json.Encoder<W> and return void!json.Error; custom decode_json methods consume a json.Value and return Self!json.Error from(value). json.from_value<T>(value) likewise consumes the view while retaining the input’s lifetime in borrowed output fields. The encoder supplies begin_object, key, end_object, begin_array, end_array, null, boolean, signed, unsigned, floating, string, json_string, and value. Its state machine rejects incomplete members and invalid token order. Custom object encoders must supply unique key names. raw validates a complete encoded value before insertion. A failed encoder stays failed, and finish() checks that one complete value was written.

Encoder.pretty(&mut writer) selects indentation; Encoder.new selects compact output. written() is the emitted byte count so far. Both maintain the same nesting, token-order, and error checks. They borrow the destination exclusively, so end the encoder’s scope before inspecting the destination directly.

The encoder is useful when producing a simple object or a primitive root without declaring a struct. It writes punctuation and escapes strings for you. A key must be followed by exactly one value, and every opened container must be closed.

package json_tokens
import "std/encoding/json"
import "std/io"
import "core/bytes"

fn example() -> void!json.Error {
    storage := [0u8; 64]
    writer := io.MemoryWriter.new(&mut storage)
    {
        encoder := json.Encoder.new(&mut writer)
        encoder.begin_object()?
        encoder.key("ok")?
        encoder.boolean(true)?
        encoder.key("n")?
        encoder.unsigned(3)?
        encoder.end_object()?
        core.drop(encoder.finish()?)
    }
    assert(bytes.equal(writer.written(), b"{\"ok\":true,\"n\":3}"))
    return ok()
}

fn main() -> i32 {
    match example() {
        ok() => { return 0 },
        err(_) => { return 1 },
    }
}

Save as json_tokens.dodo and run dodo run json_tokens.dodo. The assertion checks the complete compact output. To write a primitive root, use just the appropriate value method and finish(), without begin_object or key.

For custom decoding that must return input-borrowing fields, take_field(value, name) and take_element(value, index) consume their parent view and return Field { rest, value }. Both outputs retain the original input borrow; rest still views the complete parent, so it can be used to extract another field. take_optional_field returns OptionalField { rest, value: Option<Value> }. These helpers do not delete members or modify input; they transfer checked ownership dependencies. check_fields(keys, deny_unknown) requires an object and rejects unlisted names when deny_unknown is true; required-field checks still happen when extracting each field. Prefer derivation until you need a custom representation or validation rule.

For sequential extraction, ArrayCursor.new(value) consumes an array view. Each consuming take() returns ArrayElement { rest, value }: decode the child and continue with rest. Children retain the original input borrow and can remain live while the cursor advances. take() reports TypeMismatch at the array’s position if there are no elements left; finish() reports the same error if any remain. Taking the expected number of elements and then calling finish() checks a fixed array’s length without counting it first. Conversion errors are reported as elements are decoded, so an invalid element can be reported before a length mismatch later in the array.

The API reference includes every encoder, decoder, projection, iterator, and error declaration.

The parser, derivation, and public entry points have native execution tests at O0 and O3 plus freestanding object-emission tests for wasm32-unknown-unknown and thumbv6m-none-eabi. Object emission verifies portability of generated code; it does not execute either target.

API design references

The typed entry points and generated struct conversions draw on Serde’s derive model and Serde JSON’s typed representation. Explicit field naming follows the same practical need addressed by Serde field attributes. Strict UTF-8, case-sensitive member names, and exact array lengths follow the choices described in Go’s JSON v2 documentation. JSON for Modern C++ supplied another reference for concise everyday operations and testing exceptional behavior. Dodo adapts these ideas to checked borrows, explicit storage, and structural generic methods; it does not depend on those libraries.

Type to search all documentation.

Keyboard shortcuts

Search documentation
Ctrl K or /
Move through results
↑ ↓
Open selected result
Enter
Close a dialog
Esc
Show these shortcuts
?