Skip to content

json-ng

JSON library for Pony. All JSON values are val — construction uses chained method calls that return new values with structural sharing via persistent collections. Three access patterns are available for reading and modifying JSON structures, from simple one-shot lookups to composable paths to string-based multi-match queries.

Building JSON

JsonObject and JsonArray are constructed via chained method calls. Each call returns a new value; the original is unchanged:

use json = "json"

let doc = json.JsonObject
  .update("name", "Alice")
  .update("age", I64(30))
  .update("active", true)
  .update("tags", json.JsonArray
    .push("admin")
    .push("developer"))
  .update("address", json.JsonObject
    .update("city", "Portland")
    .update("state", "OR"))

Values in the JsonValue union — JsonObject, JsonArray, String, I64, F64, Bool, and None — can be stored in objects and arrays. JSON null is Pony's None.

Parsing JSON

JsonParser.parse() returns errors as data — no exceptions to catch:

match json.JsonParser.parse(source)
| let j: json.JsonValue =>
  // j is the parsed document (object, array, or scalar)
  match j
  | let obj: json.JsonObject =>
    env.out.print("Root is object with " + obj.size().string() + " keys")
  end
| let err: json.JsonParseError =>
  env.out.print("Error at offset " + err.offset.string() + ": "
    + err.message)
end

Reading Values: JsonNav

JsonNav wraps a value for chained read-only access. If any step in the chain fails (missing key, out-of-bounds index, type mismatch), JsonNotFound propagates silently through the rest of the chain — no partial failures or exceptions:

let nav = json.JsonNav(doc)

// Chained access — returns the value or JsonNotFound
try
  let city = nav("address")("city").as_string()?
  env.out.print("City: " + city)
end

// JsonNotFound propagates — no crash, just JsonNotFound at the end
let missing = nav("nonexistent")("deep")("path")
if not missing.found() then
  env.out.print("Path not found")
end

Terminal extractors — as_string(), as_i64(), as_f64(), as_bool(), as_null(), as_object(), as_array() — unwrap the value or raise if the type doesn't match or the nav holds JsonNotFound.

Composable Paths: JsonLens

JsonLens describes a reusable path (not tied to a specific document). It supports reading, updating, and removing values. compose() chains two lenses sequentially; or_else() tries a primary lens and falls back to an alternative:

// Define a reusable path
let host_lens = json.JsonLens("config")("database")("host")

// Read
match host_lens.get(doc)
| let host: json.JsonValue => env.out.print("Host: " + host.string())
| json.JsonNotFound => env.out.print("no host configured")
end

// Update — returns a new document with the value changed
match host_lens.set(doc, "prod.example.com")
| let updated: json.JsonValue =>
  // updated is a new doc; original doc is unchanged
  None
| json.JsonNotFound => env.out.print("path doesn't exist")
end

// Remove a key
let debug_lens = json.JsonLens("config")("debug")
match debug_lens.remove(doc)
| let updated: json.JsonValue => None // debug key removed
| json.JsonNotFound => None // path didn't exist
end

// Compose two lenses
let db_lens = json.JsonLens("config")("database")
let port_lens = db_lens.compose(json.JsonLens("port"))

// Fallback: try primary, fall back to alternative
let fallback = json.JsonLens("primary_host")
  .or_else(json.JsonLens("fallback_host"))

String-Based Queries: JsonPath

JsonPath implements a subset of RFC 9535 — string-based query expressions that can match multiple values via wildcards, recursive descent, and slicing. Parse a path string once, then apply it to any number of documents:

// Parse returns errors as data (consistent with JsonParser)
match json.JsonPathParser.parse("$.store.book[*].author")
| let path: json.JsonPath =>
  let authors = path.query(doc) // Array[JsonValue] val
  for author in authors.values() do
    env.out.print(author.string())
  end
| let err: json.JsonPathParseError =>
  env.out.print(err.string())
end

// compile() raises on invalid input — use for known-valid paths
try
  let prices = json.JsonPathParser.compile("$.store..price")?
  let results = prices.query(doc)
end

Supported JSONPath syntax:

  • $.key or $['key'] — child by name
  • $[0] or $[-1] — array index (negative counts from end)
  • $[*] or $.* — wildcard (all children)
  • $..key or $..* — recursive descent
  • $[0:3] — slice (start inclusive, end exclusive)
  • $[::2] or $[::-1] — slice with step (forward or reverse)
  • $[0,2,4] — union (multiple indices or names)
  • $[?@.price < 10] — filter by comparison
  • $[?@.author] — filter by existence (member present)
  • $[?@.a > 1 && @.b < 2] — logical AND, OR (||), NOT (!)
  • $[?@.type == $.default] — absolute query ($) in filters
  • $[?match(@.name, "[A-Z].*")] — function extensions (length, count, match, search, value)
  • query_one() — convenience returning first match or JsonNotFound

Serialization

JsonObject and JsonArray implement Stringable. Compact output uses string(); indented output uses pretty_string():

let obj = json.JsonObject
  .update("a", I64(1))
  .update("b", json.JsonArray.push(I64(2)).push(I64(3)))

env.out.print(obj.string())
// {"a":1,"b":[2,3]}

env.out.print(obj.pretty_string())
// {
//   "a": 1,
//   "b": [
//     2,
//     3
//   ]
// }

// Custom indent string (default is two spaces)
env.out.print(obj.pretty_string("\t"))

Choosing an Access Pattern

  • JsonNav — one-shot chained access. Read-only. Best for "grab this one value." Wraps a specific document; JsonNotFound propagates through chains.

  • JsonLens — reusable path with get/set/remove. Best for "define a path once, apply it to many documents." Supports composition (compose) and fallbacks (or_else). Not tied to a specific document.

  • JsonPath — string-based query language (RFC 9535 subset). Best for "find all values matching a pattern." Supports wildcards, recursive descent, and slicing. Returns arrays of results.

Start with JsonNav for simple reads. Move to JsonLens when you need to modify values or reuse paths. Use JsonPath when you need multi-match queries, wildcard selection, or user-provided path strings.

Token Parser

JsonTokenParser is a streaming alternative to JsonParser. Instead of building a complete document tree, it emits tokens to a callback as they are encountered. Use this when you need to process large documents without materializing the full tree, or when you need custom processing logic:

let parser = json.JsonTokenParser(
  object is json.JsonTokenNotify
    fun ref apply(parser': json.JsonTokenParser, token: json.JsonToken) =>
      match token
      | json.JsonTokenKey => env.out.print("Key: " + parser'.last_string)
      | json.JsonTokenString => env.out.print("String: " + parser'.last_string)
      | json.JsonTokenNumber =>
        match parser'.last_number
        | let n: I64 => env.out.print("Int: " + n.string())
        | let f: F64 => env.out.print("Float: " + f.string())
        end
      | json.JsonTokenObjectStart => env.out.print("{")
      | json.JsonTokenObjectEnd => env.out.print("}")
      | json.JsonTokenArrayStart => env.out.print("[")
      | json.JsonTokenArrayEnd => env.out.print("]")
      end
  end)
try parser.parse(source)? end

For most use cases, JsonParser.parse() is simpler and sufficient.

Public Types