Alpha. Vary is under active development and not ready for production use. Syntax, APIs, performance, and behaviour may change between releases.
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:
| 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.
Introspection
| 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 |
Error handling
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())
}
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
| Method | Returns | Description |
|---|---|---|
json.decode(text) | Result[Decoder, DecodeError] | Parse JSON string to decoder |
Decoder.from(json) | Decoder | Wrap a parsed Json value |
Navigation
| Method | Returns | Description |
|---|---|---|
field(name) | Decoder | Navigate to object field |
at(index) | Result[Decoder, DecodeError] | Navigate to array element |
Typed decode methods
| 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] |
Or-none methods
| 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]? |
Default
| Method | Returns |
|---|---|
str_or(default) | Str |
int_or(default) | Int |
float_or(default) | Float |
bool_or(default) | Bool |
Validate and transform
| Method | Returns |
|---|---|
validate_int(msg, check) | Result[Int, DecodeError] |
validate_str(msg, check) | Result[Str, DecodeError] |
transform_str(fn) | Result[Str, DecodeError] |