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.
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)
Three method families handle different requirements:
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.
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)
}
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)
}
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)
}
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()?
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(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"
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())?
The node distinguishes three states:
| State | is_missing() | is_null() | str_or_none() | str() |
|---|---|---|---|---|
| Field not in object | true | false | None | Err |
| Field is JSON null | false | true | None | Err |
| Field has a value | false | false | The value | Ok(value) |
*_or_none and *_or treat missing and null the same way. Only is_missing() and is_null() distinguish them.
| Method | Returns | Description |
|---|---|---|
path() | Str | Current field path |
keys() | List[Str] | Object keys (empty if not object) |
size() | Int | Array length (0 if not array) |
is_missing() | Bool | True if field not in parent |
is_null() | Bool | True if field is JSON null |
raw() | Json? | Escape hatch to raw Json value |
DecodeError carries four fields:
| Method | Returns | Example |
|---|---|---|
.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())
}
?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.
| Method | Returns | Description |
|---|---|---|
json.decode(text) | Result[Decoder, DecodeError] | Parse JSON string to decoder |
Decoder.from(json) | Decoder | Wrap a parsed Json value |
| Method | Returns | Description |
|---|---|---|
field(name) | Decoder | Navigate to object field |
at(index) | Result[Decoder, DecodeError] | Navigate to array element |
| Method | Returns |
|---|---|
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] |
| Method | Returns |
|---|---|
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]? |
| Method | Returns |
|---|---|
str_or(default) | Str |
int_or(default) | Int |
float_or(default) | Float |
bool_or(default) | Bool |
| Method | Returns |
|---|---|
validate_int(msg, check) | Result[Int, DecodeError] |
validate_str(msg, check) | Result[Str, DecodeError] |
transform_str(fn) | Result[Str, DecodeError] |