gleamson ✨

A pure-Gleam JSON library: a transparent value tree, a single-pass parser, and combinator decoders. No FFI, no platform JSON dependency, identical behaviour on Erlang and JavaScript.

gleam add gleamson

Why another JSON library?

gleamson is written entirely in Gleam instead of delegating to the runtime's native JSON facilities. The trade that buys you:

Honest note on speed: parsing leans on Gleam's bit-array pattern matching, which is fast on the BEAM and allocation-light. For very large payloads on the JavaScript target, a runtime's native JSON.parse (written in C++) will still win on raw throughput. If you need that, parse natively and feed the result into a decode.Decoder; the decoder layer doesn't care where the value came from.

Encoding

import gleamson.{Int, Null, Object, String}
Object([
#("name", String("Lucy")),
#("lives", Int(9)),
#("flaws", Null),
#("nicknames", gleamson.array(["Boo", "Bug"], of: String)),
])
|> gleamson.to_string
// -> {"name":"Lucy","lives":9,"flaws":null,"nicknames":["Boo","Bug"]}

Because Json is a transparent type, encoding is just building a value with its constructors. The helpers array, nullable, and from_dict cover the common shapes.

Parsing into a value

import gleamson
let assert Ok(value) = gleamson.parse("{\"user\":{\"name\":\"Ada\"}}")
gleamson.get(value, at: ["user", "name"])
// -> Ok(String("Ada"))

field, get, index, to_dict, and the as_* helpers let you walk a value without ceremony. Object entries keep their order and duplicates, so parse |> to_string round-trips faithfully.

Decoding into your own types

import gleamson
import gleamson/decode
pub type Cat {
Cat(name: String, lives: Int, nicknames: List(String))
}
pub fn cat_from_json(text: String) -> Result(Cat, decode.Error) {
let cat = {
use name <- decode.field("name", decode.string)
use lives <- decode.field("lives", decode.int)
use nicknames <- decode.field("nicknames", decode.list(decode.string))
decode.success(Cat(name:, lives:, nicknames:))
}
decode.from_string(text, cat)
}

A Decoder(t) is simply fn(Json) -> #(t, List(DecodeError)), so writing a custom one is just writing a function.

Errors accumulate. When several fields are wrong, you get every error in one go rather than stopping at the first:

// {"name": 42, "lives": "nine"} ->
// Error(CouldNotDecode([
// DecodeError("String", "Int", ["name"]),
// DecodeError("Int", "String", ["lives"]),
// ]))

Each error carries a path (e.g. ["lives"], or ["items", "2", "id"] for nested structures) pointing straight at the offending value.

Two runners let you choose how much you want back:

Combinators: field, optional_field, at, list, dict, optional, map, success, failure, and the primitives string / int / float / bool / json.

Layout

src/gleamson.gleam -- Json type, parser, encoder, value helpers
src/gleamson/decode.gleam -- combinator decoders over Json
test/gleamson_test.gleam -- examples that double as a test suite

License

Apache-2.0

More utilities

Pretty printingto_string_pretty(json) (2 spaces) or to_string_pretty_with(json, spaces: 4) for human-readable, indented output.

Mergingmerge(into:, patch:) applies a JSON Merge Patch (RFC 7386): objects merge recursively, a Null deletes a key, anything else replaces. Useful for layering config or applying partial updates.

Structural equalitysemantically_equal(a, b) compares values while ignoring object key order (arrays stay ordered). Handy in tests.

Extra decoders — alongside field / list / dict / optional:

Enum decodingenum(first, or: [...]) maps JSON strings to your own type's variants: enum(#("buy", Buy), or: [#("sell", Sell)]).

JSON Pointer (RFC 6901)pointer(value, "/a/items/0/id") looks up a value by path string; "" returns the whole document, and keys with / or ~ use the ~1 / ~0 escapes.

JSON Patch (RFC 6902)

The gleamson/patch module applies and computes patches.

import gleamson
import gleamson/patch.{Add, Replace}
let assert Ok(doc) = gleamson.parse("{\"a\":1,\"b\":[10]}")
// apply (atomic: all ops succeed, or none are applied)
let assert Ok(out) =
patch.apply(doc, [Replace("/a", gleamson.Int(2)), Add("/b/-", gleamson.Int(20))])
// diff two documents into a patch
let ops = patch.diff(from: doc, to: out)
// patches are JSON too
patch.to_json(ops) // -> a Json array
decode.run(some_json, patch.decoder()) // -> Result(List(Operation), _)

Operations: Add, Remove, Replace, Move, Copy, Test (paths are JSON Pointers). diff is correct but not minimal — array edits are positional, with no move detection.