
## 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`:

```vary
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:

```vary-snippet
import json

let node = json.decode(text)?
```

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

```vary-snippet
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:

```vary
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:

```vary
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:

```vary
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:

```vary
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:

```vary
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:

```vary-snippet
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:

```vary-snippet
let first = node.field("items").at(0)?
let name = first.field("name").str()?
```

### Mixed nesting

Object and list navigation compose freely:

```vary-snippet
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:

```vary
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:

```text
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:

```vary-snippet
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:

```vary-snippet
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:

```vary
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:

```vary-snippet
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]` |
