Skip to content
Unverified: This page has not yet been anchored to automated code tests.

RFC: @json_as(MyType) — typed JSON deserialization decorator

RFC: @json_as(MyType) — typed JSON deserialization decorator

Section titled “RFC: @json_as(MyType) — typed JSON deserialization decorator”

Status: ratified 2026-05-24.

The strict-Option Json API (json-ergonomics-rfc-2026-05-23.md) solves traversal of unknown-schema JSON well: data.pointer("/x/y") .and_then(fn(j: Json) to Option[str] { j.as_str() }). For unknown shapes (arbitrary LLM responses, CLI tool outputs) it’s the right tool.

But ~80% of Vox JSON workloads have a known schema: HTTP API responses, MCP JSON-RPC envelopes, configs, training fixtures. For these, the manual pointer + and_then + as_X ladder is ceremony — the schema is fixed, the field names are known at write time, and missing fields are typically errors (not “use a default”).

@json_as(MyType) closes the gap. One decorator on a typed struct binds Vox to the serde deserialization machinery: missing fields, type errors, and unknown variants all surface as one structured Result[MyType] instead of dozens of Option returns.

Before recommending an API, we surveyed how Rust’s #[derive(Deserialize)] shapes its trade-offs and which apply to Vox.

serde decisionWhyApply to Vox?
Generate a per-type Deserialize impl at compile timeZero-cost dispatch; no runtime schema lookupYes — decorator runs at compile, not parse
Missing required field → Err("missing field 'foo'")Loud failure for required dataYes — matches Vox’s no-silent-null principle
Option<T> field → field can be absent OR JSON nullDistinguishes optional from requiredYes — keep
#[serde(default)] for “missing → default value”Backwards-compatible config evolutionYes — @json_as(MyType, defaults: true)
#[serde(rename = "...")] for JSON name ≠ Rust namesnake_case vs camelCase mismatchYes — @field_name("foo_bar") attribute on the type def
#[serde(rename_all = "camelCase")] whole-type renameCommon when consuming JS APIsYes — @json_as(MyType, naming: "camelCase")
#[serde(tag = "type")] for tagged enumsDiscriminated unions in JSONYes — required for MCP / JSON-RPC envelopes
#[serde(deny_unknown_fields)]Strict mode for config filesYes — @json_as(MyType, strict: true)
Custom #[serde(deserialize_with = "func")]Escape hatchDefer — would require Vox-level fn pointers; revisit in v1.0+
Borrowed deserialization (&'a str)Zero-copy from input bufferNo — Vox doesn’t have lifetimes; always owned String

The principle to take: compile-time codegen, structured Result, support the common attributes (default, rename, rename_all, tag, strict). Skip the lifetime-/closure-heavy parts.

3. Realistic Vox JSON workload (audit reuse from json-ergonomics §3)

Section titled “3. Realistic Vox JSON workload (audit reuse from json-ergonomics §3)”
SourceSchema known?Shape@json_as fit
LLM tool-use response (@ai)yes (per-model schema)nested with optional action / tool_callsstrong — tagged-enum tool_calls is the killer use case
cargo metadata (audit scripts)yesdeeply nested arrays of recordsstrong — replace the Bucket-B .get(k).and_then(...) ladders
HTTP / MCP JSON-RPCyes (RFC-defined)request/response envelopesstrong — every @endpoint server response should derive
Configsyesmostly staticstrong — @json_as(MyConfig, strict: true)
process.run JSON outputvariesdepends on toolweak — use strict-Option API
LLM corpus harvest (mens)yesflatstrong — typed records save dozens of lines per row
// vox:skip — @json_as decorator is the subject of this RFC; not yet implemented.
@json_as(Product)
type Product {
id: int,
name: str,
price: float,
@field_name("in_stock") in_stock: bool,
tags: list[str],
description: Option[str],
metadata: Option[Json],
}

The decorator at the type definition generates a Product::from_json(j: Json) → Result[Product] free function (and a sibling Product::to_json(p: Product) → Json for symmetry).

ParameterTypeDefaultMeaning
(positional)type name—The Vox type to derive against. Required.
namingstr"snake_case""snake_case" / "camelCase" / "kebab-case" / "PascalCase" for the type-wide convention.
strictboolfalseWhen true, unknown JSON fields fail. When false (default), they’re ignored.
defaultsboolfalseWhen true, missing fields fall back to T::default(). When false (default), missing required fields fail.
AttributeEffect
@field_name("foo")Override per-field JSON name (escapes the type-wide naming).
@default(expr)Per-field default expression (overrides defaults: true).
@skip_if_noneWhen serializing, omit Option::None fields rather than emit null.
// vox:skip — @json_as + struct-shape variants are RFC-only.
@json_as(ToolCall, tag: "kind")
type ToolCall =
| Search { query: str }
| Compute { expr: str, precision: int }
| Done

JSON input {"kind": "Search", "query": "vox lexer"} → ToolCall::Search { query: "vox lexer" }. The tag parameter picks the discriminator field. Untagged enums (where the variant is inferred from structure) are a v2 concern; require explicit tag: for v1.

// vox:skip — Uses Product::from_json synthesized by the RFC; not yet emitted.
fn handle_response(body: str) to Result[Product] {
let json_res = json.parse(body)
if json_res.is_err() { return Err("bad JSON: " + json_res.unwrap_err()) }
return Product::from_json(json_res.unwrap())
}

The generated from_json returns Result[T] with a structured error message including a JSON Pointer path:

json_as Product: missing required field 'price' at path "/"
json_as Product: type mismatch at "/tags": expected list[str], got string
json_as Product (strict): unknown field "color" at path "/"
json_as ToolCall: unknown tag value "Unknown" at "/kind"; expected one of: Search, Compute, Done

The @json_as decorator runs at HIR-lowering time. For each tagged type, the lowering pass:

  1. Inspects the type’s field list (or variant list for enums).
  2. Resolves each field’s Vox type to its Json shape (int → Json::as_int, Option[T] → presence check via .has(k) then recurse, user-defined @json_as type → recursive call to its from_json, etc.).
  3. Generates a <TypeName>_from_json(j: Json) -> Result[<TypeName>] HirFn body.
  4. Generates a sibling <TypeName>_to_json(v: <TypeName>) -> Json.
  5. Registers the two functions in the importer’s scope.

No runtime schema: all dispatch is statically compiled.

  • No silent failures: missing fields fail loudly with a JSON Pointer path. defaults: true is explicit opt-in.
  • One canonical pattern: @json_as(T) annotation + T::from_json(j) call.
  • AI-friendly: matches Rust serde mental model 1:1.
  • K-complexity low: four optional decorator parameters; three per-field attributes; no new keywords.
  • Layered with strict-Option Json: mixed walks work via metadata: Option[Json] escape-hatch fields.
  • Custom deserialize_with / serialize_with — requires user-level fn pointers; revisit.
  • Untagged enums — defer to v2.
  • Borrowed / zero-copy deserialization — Vox has no lifetimes.
  • JSON Schema export — out of scope.
  • Streaming / partial parse — out of scope.
  • Multiple-name aliasing — defer; users wrap manually.

Tracked as Phase M of the audit doc. Six steps, ~3-5 days total:

  1. AST parser — @json_as(TypeName, params...) decorator on type declarations; per-field attributes.
  2. HIR lowering — emit two synthetic HirFns per annotated type.
  3. Typeck — register <TypeName>::from_json / <TypeName>::to_json in the importer’s TypeEnv.
  4. Eval — runtime support is regular Vox function dispatch; no special eval path.
  5. Codegen — --mode script Rust dispatch parallels the synthesized HIR.
  6. Tests + golden example — tests/json_as_test.rs + examples/golden/json_as_typed.vox.

generate-matrix-doc.vox / audit-workspace-health.vox / audit-dependency-layers.vox collapse from ~40 lines of pointer + and_then + as_X to ~10 lines of struct definition

  • one T::from_json(data) call. Post-Phase-M; doesn’t block landing.
RiskMitigation
Lowering complexity for nested genericsRecurse with a depth budget matching typeck’s existing limit.
Option[T] vs missing-vs-null ambiguityInherits from json-ergonomics §9. Explicit Json typed field for the rare PATCH case.
Tagged-enum payload shapesLimit v1 to externally-tagged. Internally-tagged + adjacently-tagged are v2.
Mutual recursionTwo-pass lowering: collect all @json_as types first, then emit bodies.
Decorator + alias-form intra-project importsimport "./types.vox" as t → t::Product::from_json(j) — works via the existing namespace-method dispatch from intra-project-imports RFC §11. Verify in integration test.