Skip to content

Decode untrusted JSON into a typed value

Goal: turn a raw JSON string from outside the program into a fully-typed — and validated — Bynk value, with a typed error when it does not fit.

Json.decode[T](s) parses a JSON string and checks it against T, returning a Result[T, JsonError]. The check covers both shape (the right fields and base types) and any refinements on T, so untrusted JSON only ever enters your program as a value that already satisfies its type’s invariants — the “make illegal states unrepresentable” idea, applied at the boundary.

commons orders {
type Order = {
id: String,
qty: Int,
}
fn parse(raw: String) -> Result[Order, JsonError] {
Json.decode[Order](raw)
}
}

The type argument is required (Json.decode[Order](raw)) — it tells the compiler what shape to validate against. A missing field, a wrong base type, or malformed JSON all produce an Err(jsonError); a well-formed payload produces Ok(order).

An Option field is the exception to “a missing field is an error”. JSON that Bynk didn’t write usually leaves an optional field out, or sets it to null, or gives the bare value, and decode accepts all three:

commons registry {
type Repo = {
repo: String,
description: Option[String],
}
fn parse(raw: String) -> Result[Repo, JsonError] {
Json.decode[Repo](raw)
}
}

{"repo": "a"} and {"repo": "a", "description": null} decode with description set to None, and {"repo": "a", "description": "d"} decodes with it set to Some("d"). Bynk’s own tagged form, which Json.encode writes, also works: {"kind": "Some", "value": "d"} and {"kind": "None"}, as long as the object has no other keys. A tagged object with an extra key, such as {"kind": "Some", "value": "d", "note": "x"}, is read as a bare value instead. A bare value is still checked as the inner type, so "description": 7 is an error, and so is that extra-key object. See the JSON codec for the full rule.

Point decode at a type whose fields are refined, and the predicates are enforced as part of decoding — no separate .of step:

commons orders {
type OrderId = String where NonEmpty
type Quantity = Int where InRange(1, 1000)
type Order = {
id: OrderId,
qty: Quantity,
}
fn parse(raw: String) -> Result[Order, JsonError] {
Json.decode[Order](raw)
}
}

{"id": "", "qty": 5000} is rejected — the empty id violates NonEmpty and 5000 is outside Quantity’s range — so a decoded Order is guaranteed valid everywhere downstream.

JsonError is an ordinary error type, so handle it like any other Result — propagate with ?, or branch with match:

commons orders {
type Order = { id: String, qty: Int }
fn quantityOf(raw: String) -> Result[Int, JsonError] {
let order = Json.decode[Order](raw)?
Ok(order.qty)
}
}

Json.encode(v) serialises a checked value to a JSON String:

commons orders {
type Order = { id: String, qty: Int }
fn render(o: Order) -> String {
Json.encode(o)
}
}

See also: Define a refined type and validate untrusted input (the .of path, for values you already have in hand), and the type-system reference.