Embedded DSLs

JSON decode

How it works

The JSON decode DSL extracts typed values from parsed JSON with automatic path tracking. When a field is missing or has the wrong type, the error message names the exact location in the document (e.g., "config.db.port: expected Int, got Str").

Import the json module to use Decoder and DecodeError:

import json

def main() {
    let node = json.decode("{\"name\": \"Alice\", \"age\": 30}").unwrap()
    let name = node.field("name").str().unwrap()
    let age = node.field("age").int().unwrap()
    print(name)
    print(age)
}

Without this DSL, the same code would use json.get_str("name") which returns Str? with no context on failure. Nested access like json.get_path("config.db.port") returns Json? and requires manual type checking. The decoder handles both navigation and type extraction in one composable API.

Creating a node

json.decode(text) parses a JSON string and returns a Result[Decoder, DecodeError]. This is the canonical entry point:

import json

let node = json.decode(text)?

If you already have a parsed Json value, use Decoder.from(json):

import json

let raw = Json.parse(text)?
let node = Decoder.from(raw)

Extracting values

Three method families handle different requirements:

Typed decode (returns Result)

Short methods return Result[T, DecodeError]. Use these for fields that must be present:

import json

def main() {
    let node = json.decode("{\"name\": \"Alice\", \"count\": 5, \"rate\": 1.5, \"active\": true}").unwrap()
    let name = node.field("name").str().unwrap()
    let count = node.field("count").int().unwrap()
    let rate = node.field("rate").float().unwrap()
    let active = node.field("active").bool().unwrap()
    print(name)
    print(count)
    print(rate)
    print(active)
}

If the field is missing, null, or the wrong type, the method returns Err with a DecodeError that includes the full path.

Or-none (returns T?)

*_or_none() methods return None if the field is missing or null:

import json

def main() {
    let node = json.decode("{\"name\": \"Alice\", \"age\": 30}").unwrap()
    let nickname: Str? = node.field("nickname").str_or_none()
    let score: Int? = node.field("score").int_or_none()
    print(nickname)
    print(score)
}

Default (returns T)

*_or methods return a fallback value if the field is missing or null:

import json

def main() {
    let node = json.decode("{\"name\": \"Alice\"}").unwrap()
    let role = node.field("role").str_or("guest")
    let retries = node.field("retries").int_or(3)
    let threshold = node.field("threshold").float_or(0.5)
    let verbose = node.field("verbose").bool_or(False)
    print(role)
    print(retries)
    print(threshold)
    print(verbose)
}

Navigating nested structures

Objects

Chain .field() calls to navigate into nested objects. The path accumulates automatically:

import json

def main() {
    let node = json.decode("{\"config\": {\"db\": {\"port\": 5432}}}").unwrap()
    let port = node.field("config").field("db").field("port").int().unwrap()
    print(port)
}

object() validates that the current value is an object and returns a node positioned at it:

import json

def main() {
    let node = json.decode("{\"config\": {\"host\": \"localhost\", \"port\": 5432}}").unwrap()
    let config = node.field("config").object().unwrap()
    let host = config.field("host").str().unwrap()
    let port = config.field("port").int().unwrap()
    print(host)
    print(port)
}

Lists

list() validates that the current value is an array and returns a list of nodes, one per element:

let items = node.field("items").list()?
for i in range(0, len(items)) {
    let name = items[i].field("name").str()?
    # Error path for element 2: "items[2].name"
}

at(index) navigates to a specific array element:

let first = node.field("items").at(0)?
let name = first.field("name").str()?

Mixed nesting

Object and list navigation compose freely:

let node = json.decode(text)?
let cases = node.field("suites").field("lexer").field("test_cases").list()?
let name = cases[1].field("name").str()?
# Error path: "suites.lexer.test_cases[1].name"

Enum constraints

enum(variants) checks that a string field is one of an allowed set:

import json

def main() {
    let node = json.decode("{\"status\": \"Active\", \"priority\": \"High\"}").unwrap()
    let status = node.field("status").enum(["Active", "Pending", "Closed"]).unwrap()
    let priority = node.field("priority").enum(["Low", "Medium", "High"]).unwrap()
    print(status)
    print(priority)
}

The match is exact and case-sensitive. On failure, the error message lists the allowed values:

at priority: expected one of [Low, Medium, High], got "critical"

Validation predicates

validate_int and validate_str extract a value and check it against a predicate in one step:

let port = node.field("port").validate_int(
    "port must be 1-65535",
    lambda n: Int: n >= 1 and n <= 65535
)?

let email = node.field("email").validate_str(
    "must contain @",
    lambda s: Str: s.contains("@")
)?

The type check runs before the predicate. If the field is missing or the wrong type, the predicate never executes.

transform_str extracts a string and applies a transformation:

let date = node.field("date").transform_str(lambda s: Str: s.split("T")[0])?
let upper = node.field("name").transform_str(lambda s: Str: s.upper())?

Missing vs. null vs. present

The node distinguishes three states:

Stateis_missing()is_null()str_or_none()str()
Field not in objecttruefalseNoneErr
Field is JSON nullfalsetrueNoneErr
Field has a valuefalsefalseThe valueOk(value)

*_or_none and *_or treat missing and null the same way. Only is_missing() and is_null() distinguish them.

Introspection

MethodReturnsDescription
path()StrCurrent field path
keys()List[Str]Object keys (empty if not object)
size()IntArray length (0 if not array)
is_missing()BoolTrue if field not in parent
is_null()BoolTrue if field is JSON null
raw()Json?Escape hatch to raw Json value

Error handling

DecodeError carries four fields:

MethodReturnsExample
.path()Field path"config.db.port"
.expected()What was expected"Int"
.actual()What was found"Str"
.message()Formatted message"at config.db.port: expected Int, got Str"

Static constructors for testing:

import json

def main() {
    let e1 = DecodeError.missing("config.host")
    let e2 = DecodeError.type_mismatch("config.port", "Int", "Str")
    print(e1.message())
    print(e2.message())
}

Composing with ?

The decoder is designed to work with the ? propagation operator. A function that decodes a complex structure reads as a flat sequence of extractions:

import json

data ServerConfig {
    host: Str
    port: Int
    debug: Bool
}

def decode_config(text: Str) -> Result[ServerConfig, DecodeError] {
    let node = json.decode(text)?
    let host = node.field("host").str()?
    let port = node.field("port").validate_int(
        "port must be 1-65535",
        lambda n: Int: n >= 1 and n <= 65535
    )?
    let debug = node.field("debug").bool_or(False)
    return Ok(ServerConfig(host, port, debug))
}

Any failure short-circuits with a DecodeError that names the exact field and what went wrong. No manual error threading, no nested match expressions.

Full API reference

Entry points

MethodReturnsDescription
json.decode(text)Result[Decoder, DecodeError]Parse JSON string to decoder
Decoder.from(json)DecoderWrap a parsed Json value

Navigation

MethodReturnsDescription
field(name)DecoderNavigate to object field
at(index)Result[Decoder, DecodeError]Navigate to array element

Typed decode methods

MethodReturns
str()Result[Str, DecodeError]
int()Result[Int, DecodeError]
float()Result[Float, DecodeError]
bool()Result[Bool, DecodeError]
object()Result[Decoder, DecodeError]
list()Result[List[Decoder], DecodeError]
enum(variants)Result[Str, DecodeError]

Or-none methods

MethodReturns
str_or_none()Str?
int_or_none()Int?
float_or_none()Float?
bool_or_none()Bool?
object_or_none()Decoder?
list_or_none()List[Decoder]?

Default

MethodReturns
str_or(default)Str
int_or(default)Int
float_or(default)Float
bool_or(default)Bool

Validate and transform

MethodReturns
validate_int(msg, check)Result[Int, DecodeError]
validate_str(msg, check)Result[Str, DecodeError]
transform_str(fn)Result[Str, DecodeError]
← Structured logging