JSON Blueprint

Define a JSON codec in Gleam to encode native values, decode JSON strictly, and publish a Draft 2020-12 schema from the same definition.

Package Version Hex Docs

JSON Blueprint 2.0 includes typed codecs, exact JSON values, runtime schema contracts, and build-time source generators in one package. The legacy decoder and schema modules remain available for applications upgrading from 1.x.

When to use it

For ordinary serialization, gleam/json is enough. Applications own HTTP, authorization, protocol policy, and effects in custom callbacks.

Capabilities

Core model

flowchart LR
  Native["Gleam type"] <-->|"encode / decode"| Codec["Codec(a)"]
  Codec -->|"JSON value"| Value["Value"]
  Codec -->|"schema"| Schema["Schema"]
  Schema -->|"from_schema"| Contract["Contract"]
  Value -->|"validate"| Contract
  Contract --> Witness["ValidatedValue"]
  Witness -->|"matching schema"| Codec

These are immutable values and synchronous calls. A Contract retains schema acceptance, while a Codec(a) owns conversion to and from an application type.

Installation

Install JSON Blueprint 2 from Hex:

gleam add json_blueprint@2

Published 1.7.1 provides the legacy json/blueprint and json/blueprint/schema modules. To stay on that API:

gleam add json_blueprint@1.7.1

Before upgrading an existing application, follow the 2.0 migration guide. The package upgrade and adopting codecs are separate changes.

Define a codec

This record codec makes a field required or optional, then uses the same definition to decode, encode, and publish the schema.

import gleam/option.{type Option}
import gleam/result
import json/blueprint/codec.{type Codec}

pub type Role {
  Admin
  Member
}

pub type User {
  User(name: String, age: Int, email: Option(String), role: Role)
}

pub fn user_codec() -> Codec(User) {
  let role = codec.string_enum([#("admin", Admin), #("member", Member)])
  use name <- codec.field("name", codec.string(), get: fn(u) { u.name })
  use age <- codec.field("age", codec.integer_between(0, 150), get: fn(u) {
    u.age
  })
  use email <- codec.optional_field("email", codec.string(), get: fn(u) {
    u.email
  })
  use role <- codec.field("role", role, get: fn(u) { u.role })
  codec.success(User(name:, age:, email:, role:))
}

pub fn round_trip() -> Result(User, String) {
  let text = "{\"name\":\"Ada\",\"age\":36,\"role\":\"admin\"}"
  use user <- result.try(
    codec.decode_json(user_codec(), text)
    |> result.map_error(codec.describe_decode_error),
  )
  let assert Ok(_text) = codec.encode_json(user_codec(), user)
  let assert Ok(_schema) = codec.schema_json(user_codec())
  Ok(user)
}

Built-in record decoding rejects unknown fields, duplicate keys, and wrong types. Errors identify the path, for example $["age"]: integer outside range 0 to 150. Custom callbacks define their own effects and laws.

Tagged unions

With json/blueprint/codec.{type Codec} imported, describe each constructor and provide the total match from the Gleam type:

pub type Shape {
  Circle(radius: Int)
  Label(text: String)
  Empty
}

pub fn shape_codec() -> Codec(Shape) {
  codec.union({
    use circle <- codec.variant("circle", codec.int(), Circle)
    use label <- codec.variant("label", codec.string(), Label)
    use empty <- codec.unit_variant("empty", Empty)
    codec.match(fn(shape) {
      case shape {
        Circle(radius) -> circle(radius)
        Label(text) -> label(text)
        Empty -> empty
      }
    })
  })
}

The wire forms are {"tag":"circle","value":2} and {"tag":"empty"}. Gleam checks that the case covers every constructor.

Parsing and runtime requirements

value.default_limits() admits at most 1 MiB of UTF-8 text. Change this with value.with_max_bytes and pass the result to codec.decode_json_with_limits, value.parse, or contract.parse. There are no global depth, value-count or numeric-token limits. Consumers own structural admission policies; a byte limit is not a heap quota.

Both targets decode built-in codecs directly from native parser output, avoiding an intermediate Value tree. Erlang/OTP 27+ uses JSON parser hooks; JavaScript uses JSON.parse with numeric source text and a duplicate-key/Unicode scan and requires runtime support for source text; older runtimes return UnsupportedRuntime. No portable parser fallback is included. Exact numbers and duplicate rejection are retained. Custom callbacks receive a converted subtree. Native parsing changes object order and precise parse locations; typed field/index errors remain. See the migration guide and browser support and executed checks.

Schema support

The runtime contract loader supports the finite profile described by codecs: closed objects, homogeneous lists, pairs, nullable values, bounded integers and numbers, string enums, tagged unions, and any JSON value. It refuses other schema forms instead of silently dropping constraints; this is not general JSON Schema validation. See the oracle and its limits.

codec.int() accepts at most 24 digits and, on JavaScript, must stay within the safe integer range. Use codec.number() for larger exact values. codec.decoder inherits the parser’s precision, duplicate-key, and resource behavior.

Upgrading from 1.x

Follow the 2.0 migration guide when upgrading from published 1.7.1. You can keep using json/blueprint and json/blueprint/schema; adopting the codec API is a separate application change. Keep the legacy decoder for recursive types. The legacy API is frozen, not formally deprecated; it has no @deprecated annotations.

  1. Build Decoder and FieldDecoder with the published combinators. Their constructors and record fields are now opaque.
  2. Replace imports of the removed json/blueprint/dynamic module with the appropriate gleam/dynamic/decode API. get_dynamic_decoder still returns a function, and its errors now use that module’s DecodeError type.
  3. Check large inputs: blueprint.decode now rejects text above 1 MiB of UTF-8 before parsing. Use blueprint.decode_with_max_bytes for an explicit larger bound.
  4. Run your stored JSON and schema fixtures before adopting codecs. Codec records reject unknown fields, optional fields distinguish absence from null, and union/enum wire forms differ from the legacy encoders.

The compiled migration example exercises both APIs and their wire differences on Erlang and JavaScript. The guide covers parser limits, schema support, exact numbers, and source generation as well.

Build-time generation

Generate reviewable Gleam source through two development-time paths:

Your build task writes, formats, and compiles the returned source. Both paths use JSON Blueprint at runtime.

The generators are included in json_blueprint under json/blueprint/codegen and json/blueprint/codegen/schema. The same dependency supplies the runtime and generators; no separate json_blueprint_codegen package is needed. Choose a dependency from Installation.

import json/blueprint/codec
import json/blueprint/codegen

pub fn names_definition() -> codegen.Definition(List(String)) {
  codegen.list(codegen.string())
}

pub fn example() {
  let assert Ok(["a", "b"]) =
    codec.decode_json(codegen.runtime(names_definition()), "[\"a\",\"b\"]")
  // In a build task: write `content` to "src/" <> path.
  let assert Ok(codegen.GeneratedModule(path:, content:, fingerprint: _)) =
    codegen.compile("generated/names_codec", "names", names_definition())
  #(path, content)
}

From the repository root in its Nix shell, regenerate its fixtures with:

gleam run -m compiled_codec_runner
gleam run -m schema_codegen_runner
gleam format test/generated

From the repository root, nix develop --command sh scripts/benchmark.sh runs the timing harness on both targets and writes a fresh evidence directory. ADR 0008 records generation ownership and the remaining scope, and ADR 0010 records the measurement policy; method, provenance and retained results are in docs/BENCHMARKS.md.

Generate native records from a schema

Examples and references

The examples compile and run on Erlang and JavaScript. The design records behavior and ownership. The benchmark report retains local observations without performance guarantees.

✨ Search Document