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.
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
- Define an API or event type once when your Gleam application needs typed JSON encoding, decoding, and a schema for that same shape.
- Validate arguments against a schema received at runtime, such as a tool declaration, when the schema fits the supported profile below.
- Admit untrusted JSON text with duplicate-key rejection, exact numbers, and a configurable byte limit. Applications own additional structural limits.
For ordinary serialization, gleam/json is enough. Applications own HTTP,
authorization, protocol policy, and effects in custom callbacks.
Capabilities
- A
Codec(a)combines native encoding and decoding; built-in codecs also describe their JSON schema. - Record codecs declare required and optional fields. Combine
optional_fieldwithnullablewhen absence and JSONnullmust have different meanings. - Tagged unions represent sum types as
{"tag": "...", "value": ...}; unit variants omitvalue. - Built-in codecs cover strings, booleans, integers, exact numbers, lists, pairs, enums, nullable values, and inclusive numeric bounds.
map,try_map, andcustomadapt application types; later fields can be checked against fields already decoded.- Encode and decode failures carry JSON paths, and definition mistakes have typed errors. Native parse-error locations are unavailable.
value.parseadmits bounded text, preserves exact JSON numbers, and rejects repeated object keys.Valuecan also bridge togleam/jsonandgleam/dynamic/decode.- Runtime
Contracts load schemas, validateValues, and decode validated values only with a codec whose schema matches. - Opaque schemas can be rendered as JSON or inspected with
codec.viewfor consumer-side translation. - Included source generators produce specialized functions for an existing native type, or native records and a codec from a supported schema.
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.
- Build
DecoderandFieldDecoderwith the published combinators. Their constructors and record fields are now opaque. - Replace imports of the removed
json/blueprint/dynamicmodule with the appropriategleam/dynamic/decodeAPI.get_dynamic_decoderstill returns a function, and its errors now use that module’sDecodeErrortype. - Check large inputs:
blueprint.decodenow rejects text above 1 MiB of UTF-8 before parsing. Useblueprint.decode_with_max_bytesfor an explicit larger bound. - 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:
- A caller-authored typed
Definition(a)generates specialized encoder, decoder, and schema functions for an existing native type. - An admitted closed-object schema generates native record types and an ordinary typed codec.
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)
}
- A
Definition(a)mirrors the codec shapes:string,int,number,bool,integer_between,pair,list,nullable,string_enum,requiredandoptionalproperties joined withcombineand closed withobject, andimapwith anamed_mapping. runtime(definition)returns its codec for interpreted use. Applications using generated functions need JSON Blueprint and generated source.compile(module_path, name, definition)returns the module source. Write it undersrc/, rungleam formaton it, and add a test that compiles it again and compares, as the compiled fixture test does here. Generation does not write files or start a process.- A generated module exports
encode_<name>,decode_<name>,encode_<name>_json,decode_<name>_json(strict parser),decode_<name>_json_native(gleam/json, 1 MiB pre-check),<name>_schema()and<name>_codec(). - The generated codec accessor uses specialized text encoding and direct typed
decoding through ordinary
codec.encode_jsonandcodec.decode_jsoncalls. Strict decoding shares the runtime parser, including duplicate-key rejection, exact numeric conversion, Unicode checks, and byte limits. No extra parser or global cache is installed. - Generated schema structures avoid rediscovering the definition; schema JSON still renders on demand. Wrapping a generated codec in an ordinary container or mapping preserves behavior and direct child decoding, but text encoding uses the ordinary Value path. Generate the whole definition to specialize it.
- A generated object codec can be a field or collection element, but cannot
finish a
codec.fieldorcodec.optional_fieldrecord continuation. Use an ordinary runtime record builder when extending a record; the generated codec replaces value, text, and schema operations, not the builder capability. - Named source callbacks must be pure and agree with their supplied runtime
functions. Generated schema and known-field metadata do not replay runtime
placeholder introspection; equal conversion results do not imply equal
callback effects during schema or field discovery.
Exact
Numberdefinitions work throughruntime, but typedcompilestill returnsNativeNumberUnsupported; the schema-driven generator below supports exact numbers through ordinary codecs.
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
- The separate
json/blueprint/codegen/schemamodule accepts an admitted closed-objectcodec.Schemaand explicit module and root names. Itscompilefunction returns the sameGeneratedModulesource record as the typed-definition generator. - A generated schema module exports native root and nested record types plus one root codec accessor. Applications use ordinary
codec.encode,codec.decode, and their strict text counterparts with that codec. - Supported children include closed records, strings, booleans, native integers and integer ranges, exact numbers and number ranges, lists, pairs, nullable values, and optional properties. Constraints and descriptions survive generation; supported structure is checked by Gleam on outbound native values while ordinary codecs check value constraints.
- Wire names are preserved. Native names are deterministic; invalid names, name collisions, and unsupported shapes return typed failures with logical schema locations instead of partial source.
- Enums, tagged unions, unconstrained values, shared or recursive definitions, and non-object roots are outside this generation slice. Runtime-schema admission does not imply generation support.
- The separate order consumer demonstrates the complete workflow, including a schema file, development-only generation task, committed output, application-owned policy, recovery, both runtime targets, and freshly generated-code integration tests. Its production package depends only on JSON Blueprint and compiled source.
- Generated codecs use public runtime combinators and add no second contract-validation pass. Codec accessors construct values on each call; retain a codec for repeated use. ADR 0010 records the separate construction, conversion, and build-time generation costs.
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.