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.
Decode into a record
Section titled “Decode into a record”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.
Decode straight into a refined type
Section titled “Decode straight into a refined type”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.
Handle the result
Section titled “Handle the result”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) }}Encode the other way
Section titled “Encode the other way”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.