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)
}

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
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]