json/blueprint/codec
Bidirectional JSON codecs: one Codec(a) encodes a Gleam value, decodes
JSON text, and describes the JSON as a Draft 2020-12 schema.
Describe a record once with field, optional_field and success, then
use it in both directions:
import gleam/option.{type Option}
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 example() -> Result(String, String) {
let text = "{\"name\":\"Ada\",\"age\":36,\"role\":\"admin\"}"
case codec.decode_json(user_codec(), text) {
Error(error) -> Error(codec.describe_decode_error(error))
Ok(user) -> {
let assert Ok(text) = codec.encode_json(user_codec(), user)
Ok(text)
}
}
}
Each getter is passed with its get: label and needs no type
annotation: the rest of the use block, which ends in success, is
checked first and fixes the record type. The body after each use runs
with placeholder values when the codec describes itself, so keep it a
plain constructor call.
Objects are closed: an unknown field fails to decode. optional_field
omits the field for None; wrap the inner codec in nullable to also
accept null. union with variant and unit_variant describes a sum
type as {"tag": ..., "value": ...}. map and try_map convert to your
own types, custom builds a codec from functions, and value passes any
JSON through as a Value. placeholder gives a generic wrapper the
value a codec describes itself with. A check across
fields reports its path when the dependent field’s codec is built from
the fields before it; see try_map.
decode_json applies a 1 MiB byte bound and rejects duplicate keys.
decode_json_with_limits takes a different byte bound. Both targets decode
built-in codecs directly from the native parse tree; custom codecs and
value() materialize their requested Value subtree. JavaScript uses
JSON.parse with exact source retention and strict duplicate/Unicode checks.
Neither target applies a depth or value-count limit.
to_json and decoder bridge to gleam/json and
gleam/dynamic/decode.
A definition written wrongly, such as a field named twice or an enum label
repeated, panics with a message naming the field or label when the
mistaken part is first used: when it encodes or decodes a value, or when
schema describes the codec. check returns the same problem as a
DefinitionError instead, for codecs built from runtime data such as enum
labels loaded from a database; call it at startup.
DecodeError and EncodeError are {path, reason} records, rendered
by describe_decode_error and describe_encode_error: the library owns
the path, and a Custom reason renders the caller’s message. Reason
may gain variants in minor releases; match it with a _ branch, and
build errors with decode_failure and encode_failure.
schema returns an opaque Schema. schema_value and
schema_document render it as JSON Schema; code that translates a
schema, such as a provider adapter, reads it with view, which gives
the kind at the root, and description. view has a fixed set of
variants for 2.x, and a kind added later reaches it as
OtherSchema(document), so a match like this one keeps compiling:
import gleam/list
/// Whether a provider that takes strings, integers, booleans, lists and
/// closed objects of them can take `schema`.
pub fn supported(schema: codec.Schema) -> Bool {
case codec.view(schema) {
codec.StringSchema
| codec.StringEnumSchema(_)
| codec.IntSchema
| codec.IntegerRangeSchema(_, _)
| codec.BoolSchema -> True
codec.ListSchema(items) -> supported(items)
codec.ObjectSchema(properties) ->
list.all(properties, fn(property) { supported(property.schema) })
codec.NumberSchema
| codec.NumberRangeSchema(_, _)
| codec.PairSchema(_, _)
| codec.NullableSchema(_)
| codec.UnionSchema(_)
| codec.AnySchema -> False
// A kind added in a later 2.x release: reject what is not known.
codec.OtherSchema(_) -> False
}
}
Types
Encodes a to JSON, decodes JSON to a, and describes the JSON.
pub opaque type Codec(a)
A decoding failure at path. Read fields by label.
pub type DecodeError {
DecodeError(path: List(PathSegment), reason: Reason)
}
Constructors
-
DecodeError(path: List(PathSegment), reason: Reason)
A mistake in a codec definition. The strings name the offending field, tag or label from the definition, never input data.
pub type DefinitionError {
DuplicateFieldName(name: String)
NotARecord(after_field: String)
DuplicateTag(tag: String)
EmptyUnion
EmptyEnum
DuplicateEnumLabel(label: String)
DuplicateEnumValue(label: String)
ReversedIntegerBounds(minimum: Int, maximum: Int)
ReversedNumberBounds(
minimum: number.Number,
maximum: number.Number,
)
UnsafeIntegerBound(bound: Int)
}
Constructors
-
DuplicateFieldName(name: String) -
NotARecord(after_field: String)The codec after a
fieldoroptional_fieldis not another field orsuccess. -
DuplicateTag(tag: String) -
EmptyUnion -
EmptyEnum -
DuplicateEnumLabel(label: String) -
DuplicateEnumValue(label: String)Two labels map to the same value;
labelis the second. -
ReversedIntegerBounds(minimum: Int, maximum: Int) -
ReversedNumberBounds( minimum: number.Number, maximum: number.Number, ) -
UnsafeIntegerBound(bound: Int)On JavaScript, a bound of
integer_betweenoutside the safe range.
An encoding failure at path. Read fields by label.
pub type EncodeError {
EncodeError(path: List(PathSegment), reason: Reason)
}
Constructors
-
EncodeError(path: List(PathSegment), reason: Reason)
A step of the path from the root of a value to a failure.
pub type PathSegment {
Field(name: String)
Index(index: Int)
}
Constructors
-
Field(name: String) -
Index(index: Int)
Why a value failed to decode or encode. Typed decoding can report JSON,
structure, native conversion and contract-matching failures. Typed encoding
can report enum values, native numeric safety and nullable failures.
contract.value_codec can also report structure and label failures while
encoding. Both directions can report numeric bounds and Custom failures;
to_json can additionally report UnrepresentableNumber.
May gain variants in a minor release; match with a _ branch.
pub type Reason {
InvalidJson(value.ParseError)
ExpectedString
ExpectedInt
ExpectedNumber
ExpectedBool
ExpectedArray
ExpectedObject
WrongLength(expected: Int, actual: Int)
MissingField
UnknownField
DuplicateField
UnknownEnumLabel
UnknownTag
FloatOutOfRange
ContractMismatch
UnknownEnumValue
UnsafeInteger
NonFiniteFloat
NullInsideNullable
UnrepresentableNumber(number.Number)
IntegerOutsideRange(minimum: Int, maximum: Int)
NumberOutsideRange(
minimum: number.Number,
maximum: number.Number,
)
Custom(message: String)
}
Constructors
-
InvalidJson(value.ParseError)decode_jsoncould not parse the text; the path is empty. -
ExpectedString -
ExpectedInt -
ExpectedNumber -
ExpectedBool -
ExpectedArray -
ExpectedObject -
WrongLength(expected: Int, actual: Int) -
MissingField -
UnknownField -
DuplicateField -
UnknownEnumLabel -
UnknownTag -
FloatOutOfRangeThe number is beyond the largest finite float.
-
ContractMismatchcontract.decode: the codec’s schema differs from the contract’s. -
UnknownEnumValue -
UnsafeIntegerOn JavaScript, an
Intoutside the safe integer range. -
NonFiniteFloatOn JavaScript, an infinite or NaN
Float. -
NullInsideNullablenullablewas givenSome(x)whose inner codec encodesxasnull. -
UnrepresentableNumber(number.Number)to_json: the number has no exactgleam/jsonform. -
IntegerOutsideRange(minimum: Int, maximum: Int) -
NumberOutsideRange( minimum: number.Number, maximum: number.Number, ) -
Custom(message: String)A message from
try_map,decode_failureorencode_failure.
The JSON Schema of a codec, within the finite Draft 2020-12 profile that
codecs describe. Get one with schema, render it with schema_value or
schema_document, and read its structure with view and description.
It is opaque so that the schema model can grow without breaking callers;
see SchemaView for the evolution policy. Two schemas are == when they
have the same structure and descriptions; contract.same_schema ignores
descriptions and member order.
pub opaque type Schema
A codec built by custom without a schema has none to describe.
pub type SchemaError {
UnknownSchema
}
Constructors
-
UnknownSchema
The kind of a schema at its root, for code that translates schemas, such
as a tool server normalising an input schema or an LLM client converting
one for a provider. Children are Schema values: call view on them to
walk the tree.
Evolution policy: these variants are fixed for every 2.x release. A schema
kind added in a minor release reaches view as OtherSchema(document),
where document is that kind’s JSON Schema object, as schema_value
renders it. A consumer written against 2.0 therefore keeps compiling and
decides in its OtherSchema branch whether to forward the document or
reject the schema. A dedicated variant for a new kind is a breaking change
and arrives only in a major release; until then a minor release may add a
function that reads it. No 2.0 schema produces OtherSchema.
Descriptions are annotations, not kinds: view looks through them and
description reads them, so a described string is still a
StringSchema.
pub type SchemaView {
StringSchema
StringEnumSchema(labels: List(String))
IntSchema
IntegerRangeSchema(minimum: Int, maximum: Int)
NumberSchema
NumberRangeSchema(
minimum: number.Number,
maximum: number.Number,
)
BoolSchema
PairSchema(left: Schema, right: Schema)
ListSchema(items: Schema)
NullableSchema(inner: Schema)
ObjectSchema(properties: List(PropertySchema))
UnionSchema(variants: List(VariantSchema))
AnySchema
OtherSchema(document: value.Value)
}
Constructors
-
StringSchemastring():{"type": "string"}. -
StringEnumSchema(labels: List(String))string_enum:{"type": "string", "enum": labels}. -
IntSchemaint():{"type": "integer"}. -
IntegerRangeSchema(minimum: Int, maximum: Int)integer_between: an integer with inclusive bounds. -
NumberSchemafloat()andnumber():{"type": "number"}. -
NumberRangeSchema(minimum: number.Number, maximum: number.Number)number_between: a number with inclusive bounds. -
BoolSchemabool():{"type": "boolean"}. -
pair: an array of exactly two items. -
ListSchema(items: Schema)list: an array whose items all matchitems. -
NullableSchema(inner: Schema)nullable:nullorinner. -
ObjectSchema(properties: List(PropertySchema))A record: a closed object.
-
UnionSchema(variants: List(VariantSchema))union: a closed{"tag": ..., "value": ...}object, one variant per tag. -
AnySchemavalue(): any JSON value,{}in a document. -
OtherSchema(document: value.Value)A kind added after 2.0, as its JSON Schema object. See the evolution policy above.
A value of a union with its tag, made by the function that variant or
unit_variant passes on.
pub opaque type Tagged(t)
One variant of a UnionSchema. A unit variant has no payload. May gain
fields in a minor release; read them by label.
pub type VariantSchema {
VariantSchema(tag: String, payload: option.Option(Schema))
}
Constructors
-
VariantSchema(tag: String, payload: option.Option(Schema))
Values
pub fn check(
codec: Codec(a),
) -> Result(Codec(a), DefinitionError)
Report a definition mistake without panicking. Use it for codecs built from runtime data, such as enum labels or bounds loaded at runtime; the other functions panic on the same mistake.
pub fn custom(
encode encode: fn(a) -> Result(value.Value, EncodeError),
decode decode: fn(value.Value) -> Result(a, DecodeError),
schema schema: option.Option(Schema),
placeholder placeholder: a,
) -> Codec(a)
A codec from your own functions over Value. schema is its schema, or
None when it has none, in which case schema returns UnknownSchema
and so does every codec built from this one. Take the schema from the
codec whose JSON yours matches, such as
option.from_result(codec.schema(codec.string())), or from a contract
with contract.schema. Build errors with decode_failure and
encode_failure, or return those of other codecs. placeholder is any
value of a.
pub fn decode(
codec: Codec(a),
raw: value.Value,
) -> Result(a, DecodeError)
Decode a Value.
pub fn decode_failure(message: String) -> DecodeError
A decoding failure with Custom(message) at the current path, for
custom decoders.
pub fn decode_json(
codec: Codec(a),
text: String,
) -> Result(a, DecodeError)
Parse JSON text within the default byte bound and decode it.
Duplicate keys are rejected; numeric projection follows the selected codec.
A parse failure is
DecodeError([], InvalidJson(error)).
pub fn decode_json_with_limits(
codec: Codec(a),
text: String,
limits: value.Limits,
) -> Result(a, DecodeError)
Like decode_json, with a caller-selected byte bound.
pub fn decoder(codec: Codec(a)) -> decode.Decoder(a)
A gleam/dynamic/decode decoder, for libraries that take one, such as
json.parse(text, codec.decoder(user_codec())).
The parser that produced the data owns duplicate keys, number precision
and size limits; see value.decoder. A failure is one decode.DecodeError
whose expected is the describe_decode_error text.
pub fn describe(codec: Codec(a), description: String) -> Codec(a)
Add a JSON Schema description without changing encoding or decoding. A later description at the same node replaces an earlier one.
pub fn describe_decode_error(error: DecodeError) -> String
Render a decode error as text such as
$["user"]["age"]: integer outside range 0 to 150. The library writes the
path and the text of its own reasons, which contain no input values and no
unknown or repeated keys from the input. A Custom(message) renders as
the message, verbatim, such as $["total"]: total 4 differs from 3, so
leave input values out of the messages that must not show them. An empty
message renders as custom validation failed.
pub fn describe_definition_error(
error: DefinitionError,
) -> String
Render a definition error as text such as field "name" appears twice.
pub fn describe_encode_error(error: EncodeError) -> String
Render an encode error as text such as
$["role"]: value is not in the enum, like describe_decode_error.
pub fn description(schema: Schema) -> option.Option(String)
The description that describe gave schema, if any. The schema of a
field, an item or a payload has its own description.
pub fn encode(
codec: Codec(a),
item: a,
) -> Result(value.Value, EncodeError)
Encode to a Value.
pub fn encode_failure(message: String) -> EncodeError
An encoding failure with Custom(message) at the current path, for
custom encoders.
pub fn encode_json(
codec: Codec(a),
item: a,
) -> Result(String, EncodeError)
Encode to compact JSON text.
pub fn field(
named name: String,
of codec: Codec(a),
then next: fn(a) -> Codec(r),
get get: fn(r) -> a,
) -> Codec(r)
A required field of a record, followed by the rest of the record:
use name <- codec.field("name", codec.string(), get: fn(u) { u.name })
get reads the field when encoding. The rest of the record is the codec
that next returns: another field or optional_field, or success.
Pass the getter with its get: label. Gleam checks arguments in
parameter order, and next (the rest of the use block) comes before
get, so the record type is known from success by the time the getter
is checked and the getter needs no annotation. Without the label the
getter fills the then slot and the call fails to type-check.
pub fn float() -> Codec(Float)
A JSON number as a Float, with the schema {"type": "number"}.
Decoding rounds to the nearest float, so 0.1 decodes to 0.1 although
that float is not exactly one tenth; a number beyond the float range
fails with FloatOutOfRange. Encoding writes the shortest decimal that
reads back as the same float. Use number() to keep numbers exact.
pub fn int() -> Codec(Int)
A JSON integer as an Int, with the schema {"type": "integer"}.
Decoding accepts any exact integer spelling, such as 12, 12.0 or
1.2e1, of at most 24 digits. A longer integer, a fraction, or on
JavaScript an integer outside ±9,007,199,254,740,991 fails with
ExpectedInt; use number() for larger values. Encoding also refuses
integers longer than 24 digits with ExpectedInt; unsafe JavaScript
integers fail with UnsafeInteger.
pub fn integer_between(minimum: Int, maximum: Int) -> Codec(Int)
An integer from minimum to maximum inclusive, with those bounds in the
schema. Reversed bounds are a definition mistake.
pub fn is_limit_exceeded(error: DecodeError) -> Bool
Whether decoding failed because the text exceeded a parse limit, such as the 1 MiB byte limit, rather than being invalid.
pub fn map(
codec: Codec(a),
decode from: fn(a) -> b,
encode to: fn(b) -> a,
) -> Codec(b)
Convert a codec to another type with total conversions in both
directions, such as map(codec.string(), decode: Email, encode: fn(e) { e.address }). The schema is unchanged.
pub fn match(encode: fn(t) -> Tagged(t)) -> Union(t)
The end of a union: the function that maps each value to its case.
pub fn nullable(inner: Codec(a)) -> Codec(option.Option(a))
null or the inner codec’s JSON, as an Option. Some(x) whose inner
encoding is itself null fails with NullInsideNullable, because it
would decode as None.
pub fn number_between(
minimum: number.Number,
maximum: number.Number,
) -> Codec(number.Number)
A number from minimum to maximum inclusive, exactly, with those bounds
in the schema. Reversed bounds are a definition mistake.
pub fn optional_field(
named name: String,
of codec: Codec(a),
then next: fn(option.Option(a)) -> Codec(r),
get get: fn(r) -> option.Option(a),
) -> Codec(r)
An optional field of a record: absent decodes as None, and None
encodes as an absent field. JSON null fails to decode unless the inner
codec is nullable; with nullable(c), absent, null and a value decode
as None, Some(None) and Some(Some(x)). Pass the getter with its
get: label, as for field:
use email <- codec.optional_field("email", codec.string(), get: fn(u) {
u.email
})
pub fn placeholder(codec: Codec(a)) -> a
A value of a that the codec holds for describing itself, for a generic
wrapper that needs one without input: the placeholder of a try_map
or custom over this codec, or decode.failure in a hand-written
gleam/dynamic/decode decoder. It is the value the codec was built
with, such as "" for string(), minimum for integer_between, the
first variant of a union or the record built from its fields’
placeholders. It need not be valid input and is not a default: never
encode it as data.
pub fn non_empty(inner: Codec(List(a))) -> Codec(List(a)) {
codec.try_map(
inner,
decode: fn(items) {
case items {
[] -> Error("expected at least one item")
_ -> Ok(items)
}
},
encode: Ok,
placeholder: codec.placeholder(inner),
)
}
pub fn schema_document(schema: Schema) -> value.Value
The complete Draft 2020-12 schema document: schema_value with $schema.
pub fn schema_json(
codec: Codec(a),
) -> Result(String, SchemaError)
The complete Draft 2020-12 schema document as JSON text.
pub fn schema_value(schema: Schema) -> value.Value
The schema as a JSON Schema object, without $schema. AnySchema is
{}.
pub fn string_enum(variants: List(#(String, a))) -> Codec(a)
A string from a fixed set of labels, each mapped to a value, such as
string_enum([#("admin", Admin), #("member", Member)]). The JSON is the
bare label. An empty list, a repeated label or two labels with the same
value are definition mistakes.
pub fn success(value: r) -> Codec(r)
The end of a record: the value built from the decoded fields. Alone,
success(x) is the empty object {}, which decodes as x.
pub fn to_json(
codec: Codec(a),
item: a,
) -> Result(json.Json, EncodeError)
Encode to gleam/json, for libraries that take json.Json. Numbers are
exact: a number with no exact gleam/json form, such as 1e400, fails
with UnrepresentableNumber. Codecs of Int, Float and String
never produce one.
pub fn try_map(
codec: Codec(a),
decode from: fn(a) -> Result(b, String),
encode to: fn(b) -> Result(a, String),
placeholder placeholder: b,
) -> Codec(b)
Like map, with conversions that may fail with a message. A failure is
Custom(message) at the codec’s path. placeholder is any value of b,
used where a value is needed without input, as decode.failure does.
Over a whole record, the path of a failure is the record’s own, [] at
the root. To report a check across fields at the field it concerns, build
that field’s codec inside the record from the fields decoded before it:
use items <- codec.field("items", codec.list(codec.int()), get: fn(o) {
o.items
})
use total <- codec.field("total", total_of(items), get: fn(o) { o.total })
codec.success(Order(items:, total:))
where total_of(items) is a try_map over codec.int() that fails when
the total differs from the sum. A failure then has the path
[Field("total")], in both directions. When the codec describes itself,
total_of receives placeholder values, so its schema must not depend on
them.
pub fn union(variants: Union(t)) -> Codec(t)
A codec for a sum type. Each case is a variant with a payload or a
unit_variant without one, and match maps each value to its case:
codec.union({
use circle <- codec.variant("circle", codec.int(), Circle)
use square <- codec.variant("square", codec.int(), Square)
use empty <- codec.unit_variant("empty", Empty)
codec.match(fn(shape) {
case shape {
Circle(radius) -> circle(radius)
Square(side) -> square(side)
Empty -> empty
}
})
})
The JSON is {"tag": "circle", "value": 2}; a unit variant has no
"value". Gleam checks the case for exhaustiveness. A repeated tag or a
union with no variants is a definition mistake.
pub fn unit_variant(
tag: String,
value item: t,
then next: fn(Tagged(t)) -> Union(t),
) -> Union(t)
A case of a union without a payload, such as Empty. next receives the
tagged value, for use in match.
pub fn value() -> Codec(value.Value)
Any JSON value, passed through unchanged as a Value: for JSON that the
application forwards or inspects itself, such as a tool result from a
remote peer. Decoding accepts every value and encoding returns it; the
parse limits of decode_json still bound it.
Its schema is AnySchema, which renders as {}, the JSON Schema that
accepts every value; a record with a value() field describes that field
as {}. When a narrower schema for the value is known, use
contract.value_codec, which validates the value on encoding and decoding.
pub fn variant(
tag: String,
of payload: Codec(p),
construct construct: fn(p) -> t,
then next: fn(fn(p) -> Tagged(t)) -> Union(t),
) -> Union(t)
A case of a union whose JSON carries a payload. next receives the
function that tags a payload as this case, for use in match.
pub fn view(schema: Schema) -> SchemaView
The kind of schema at its root, after any description. Children are
Schema values; view them in turn. The module doc has an example.