Migrating JSON Blueprint from 1.7.1 to 2.0

JSON Blueprint 2.0 includes the legacy decoder and schema modules alongside typed codecs, exact JSON values, finite runtime contracts, and source generators. Upgrading the package can retain your legacy wire format. Moving an application to codecs requires a separate review of its accepted and emitted JSON.

This guide compares published 1.7.1 with the 2.0.0 checkout. It does not claim complete equivalence for every legacy schema, error path, or numeric boundary.

Update the dependency

Until 2.0.0 is published to Hex, use a Git or local path dependency on this checkout. Keep the resolved Git commit in manifest.toml. After publication, update the application with:

gleam add json_blueprint@2
gleam build --warnings-as-errors
gleam test

Run every target your application supports. Update each independently resolved example, adapter, and consumer through Gleam as well. Do not replace a historical oracle dependency merely to make it match the application dependency.

Keep the legacy API first

The json/blueprint combinators and json/blueprint/schema remain available. They are frozen, not formally deprecated, and have no @deprecated annotations. Existing legacy union, enum, optional, tuple, and recursive forms can stay on that API while you review a codec migration. Address these package-upgrade changes before changing the application’s wire format:

Change in 2.0Application action
Decoder and FieldDecoder are opaqueReplace constructor calls, pattern matches, and field access with supported combinators and accessors. Use get_dynamic_decoder to obtain the decoding function and generate_json_schema to render a decoder’s schema.
json/blueprint/dynamic has been removed from the public module pathsMove dynamic decoding and error types to gleam/dynamic/decode. Its API is not a mechanical module-name replacement for every old helper.
get_dynamic_decoder returns standard-library decode errorsUpdate error annotations and constructor matches to gleam/dynamic/decode.DecodeError. The fields remain expected, found, and path. The return value is still fn(Dynamic) -> Result(t, List(decode.DecodeError)), rather than a standard-library Decoder(t).
blueprint.decode has a UTF-8 byte precheckText above 1,048,576 bytes returns json.UnableToDecode before native parsing. Use blueprint.decode_with_max_bytes(using:, from:, max_bytes:) with an application-selected bound for larger inputs.

Custom code that constructed raw decoder records may need redesign. There is no promised one-for-one replacement for those constructors. Prefer a supported composition of legacy combinators, or review a codec conversion for that boundary.

The legacy path continues to use gleam/json for parsing. Raising its byte bound does not add strict duplicate-key, nesting, value-count, or exact-number guarantees. Keep self_decoder and reuse_decoder for recursive types; the finite codec and runtime-contract profile does not support recursive schemas.

Applications feeding JavaScript objects directly to a Dynamic decoder should also replay their object-lookup fixtures. Lookup now handles Gleam Dicts, Maps, WeakMaps, null-prototype objects, and present undefined entries more consistently. These corrections affect direct Dynamic input; the retained legacy JSON union and enum encoders keep their wire forms.

Adopt codecs one boundary at a time

A codec.Codec(a) describes both native conversion directions and a schema. Build it with public constructors, use codec.field or codec.optional_field with a native getter for each record member, and finish the record with codec.success. Use codec.decode_json, codec.encode_json, and codec.schema_json at the boundary.

The compiled migration example converts one legacy Decoder(Person) to a Codec(Person) and checks both implementations. It also exercises these intentional differences:

BoundaryRetained legacy APICodec API
Record membersNonempty record decoders ignore unknown membersBuilt-in record codecs reject unknown members
Optional string propertyMissing and null both decode to NoneA plain optional_field accepts absence and rejects null
Nullable optional propertyAbsence and null may collapseoptional_field containing nullable represents absence, null, and a value as None, Some(None), and Some(Some(value))
Payload unionThe encoder emits {"type":"circle","data":{"radius":2}} for the example’s payloadIts codec emits {"tag":"circle","value":2}
EnumThe encoder emits {"enum":"high"}A string-enum codec emits "high"

Capture representative stored values and protocol messages before changing a boundary. Include missing, null, extra, malformed, and recursive cases. Check both decoding and emitted JSON against those fixtures. If stored data or peers still use the old form, retain a legacy reader or implement an explicit application migration before switching writers. There is no automatic conversion between arbitrary legacy decoders and codecs.

The optional-field example shows the three-state representation and its encoding behavior.

Review parser and number policy

codec.decode_json admits at most 1 MiB of UTF-8 by default and rejects repeated object keys. Use codec.decode_json_with_limits and value.with_max_bytes to select another byte limit. Depth, value-count and global numeric admission limits and their setters have been removed. Consumers that require those policies must inspect their decoded data. Explicit number.parse(token, limits) still supports caller-selected numeric limits. On JavaScript, raising those limits cannot bypass exact integer representation: unrepresentable written or normalized exponents and comparison magnitudes return ExponentOutOfRange rather than silently rounding. Erlang retains exact arbitrary-size integer arithmetic.

On Erlang/OTP 27 or later, parsing uses OTP’s JSON decoder with duplicate detection and exact numeric hooks. JavaScript uses native JSON.parse with source-text retention for numbers, followed by a scan for decoded duplicate names and malformed escaped surrogate pairs. Both targets decode built-in codecs from the native tree; custom Value callbacks convert only their subtree. value.parse still constructs a complete Value tree. This is not streaming: the JavaScript native tree is allocated before duplicate-key rejection.

JavaScript strict parsing requires reviver context.source support. The old portable parser fallback has been removed. Runtimes without this capability return UnsupportedRuntime, not a syntax error or rounded values. This applies to strict text/byte parsing and codec JSON decoding; the frozen legacy API is unchanged. See browser/runtime requirements and executed checks.

Diagnostics and object order

Native parsing changes the JavaScript diagnostic contract as well as the Erlang contract. Do not match native error wording or depend on source-order errors:

The byte bound is checked first. parse_bits rejects invalid UTF-8 and a leading BOM. JavaScript exact decimal exponents and comparison magnitudes must fit its safe-integer representation. Deep structures or long numeric tokens can exceed host capacity and return RuntimeCapacityExceeded; the byte limit is not a memory or stack quota. User callback exceptions remain owned by the caller. Valid surrogate pairs and the literal replacement character remain accepted.

codec.int() supports at most 24 digits in both directions and requires safe integers on JavaScript. Encoding a longer Erlang integer returns ExpectedInt; integer_between retains its separate bound-derived digit allowance. Use codec.number() and the exact Number operations for values that must retain mathematical JSON precision. Treat projection into native Int or Float as a separate operation that can fail or round. Use codec.float() when binary floating-point conversion is acceptable. The gleam/json and standard dynamic-decoder bridges retain their native parser and number behavior; they do not inherit strict text-admission guarantees.

See the larger-input example for explicit limits. Run large-number fixtures on each intended target.

Review schemas and runtime contracts

Legacy schema rendering remains in json/blueprint/schema. Its declared Draft-07 label does not prove that every emitted keyword belongs to that dialect or that the decoder enforces every described constraint. Keep schema snapshots when retaining that API.

Modern codecs render Draft 2020-12 schemas through an opaque codec.Schema. Use codec.view and its opaque children to inspect a schema. Runtime contracts admit only the finite profile of supported shapes; a broader legacy description cannot be passed through as an equivalent contract. References, recursive definitions, and other unsupported forms are refused. Review provider-specific schema restrictions in the application as well.

contract.parse loads schema text with explicit admission limits, and contract.validate checks a Value. Decoding a validated value requires a codec with the same normalized schema. These checks do not certify custom callback behavior or application authorization. The oracle documentation states the supported profile and the limits of its independent evidence.

Keep generation in the build

The package now includes json/blueprint/codegen and json/blueprint/codegen/schema. The separate json_blueprint_codegen package was an unpublished checkout package; remove that dependency if you used it. Keep the same generator imports and depend on json_blueprint alone.

Generation returns source. Your build task writes, formats, compiles, and reviews that source for each target. Regenerate checked-in modules with the 2.0 generator, review the diff, and rerun parity and application tests before committing the output. The schema generator’s attribution header now names JSON Blueprint. Do not edit generator output by hand to make a drift check pass.

Typed-definition output now registers specialized text encoding and direct strict decoding on its codec accessor. Ordinary codec.encode_json and codec.decode_json therefore use those functions. Strict decoding shares the runtime parser and its duplicate-key, Unicode, exact-number and byte guarantees; it no longer constructs an intermediate complete Value tree. The explicitly named decode_<name>_json_native remains the weaker standard-JSON entry point.

Two observable details now align with runtime codecs. The shared generated Value decoder checks declared fields before unknown or repeated members, including for existing generated modules. Regenerated codec accessor construction no longer evaluates mapped placeholders eagerly. Placeholder mappings run when a placeholder is needed. Strict text duplicates still fail before typed callbacks. Replay multi-error fixtures and any mapping callbacks with observable effects; source references must match the supplied runtime functions. Mappings should be pure: generated schema and known-field metadata do not replay runtime placeholder introspection. Conversion order is preserved, but introspection callback counts are not an equivalence guarantee.

Specialization does not expand the typed generator’s supported shapes. Exact Number compilation still returns NativeNumberUnsupported. Schema-driven codecs retain exact-number support through ordinary runtime combinators. Generated schema structures are retained, but schema JSON text is rendered on demand. Ordinary composition around a generated codec preserves semantics and direct child decoding; its outer text encoder uses the ordinary Value path. As before, a typed-generated object codec can be nested as a child, but cannot finish a codec.field or codec.optional_field record continuation. Use an ordinary runtime record builder to extend a record. Replacement equivalence covers value, text, and schema operations, not record-builder capability.

Schema-driven generation supports a narrower closed-object subset than runtime contracts. Admitting a schema as a contract does not guarantee generation support.

The generation consumer shows a separate build task, generated records, application policy, and production compilation. Generation does not create new native types at application runtime.

✨ Search Document