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

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 field or optional_field is not another field or success.

  • DuplicateTag(tag: String)
  • EmptyUnion
  • EmptyEnum
  • DuplicateEnumLabel(label: String)
  • DuplicateEnumValue(label: String)

    Two labels map to the same value; label is the second.

  • ReversedIntegerBounds(minimum: Int, maximum: Int)
  • ReversedNumberBounds(
      minimum: number.Number,
      maximum: number.Number,
    )
  • UnsafeIntegerBound(bound: Int)

    On JavaScript, a bound of integer_between outside the safe range.

An encoding failure at path. Read fields by label.

pub type EncodeError {
  EncodeError(path: List(PathSegment), reason: Reason)
}

Constructors

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)

One property of an ObjectSchema. May gain fields in a minor release; read them by label.

pub type PropertySchema {
  PropertySchema(name: String, required: Bool, schema: Schema)
}

Constructors

  • PropertySchema(name: String, required: Bool, schema: Schema)

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_json could not parse the text; the path is empty.

  • ExpectedString
  • ExpectedInt
  • ExpectedNumber
  • ExpectedBool
  • ExpectedArray
  • ExpectedObject
  • WrongLength(expected: Int, actual: Int)
  • MissingField
  • UnknownField
  • DuplicateField
  • UnknownEnumLabel
  • UnknownTag
  • FloatOutOfRange

    The number is beyond the largest finite float.

  • ContractMismatch

    contract.decode: the codec’s schema differs from the contract’s.

  • UnknownEnumValue
  • UnsafeInteger

    On JavaScript, an Int outside the safe integer range.

  • NonFiniteFloat

    On JavaScript, an infinite or NaN Float.

  • NullInsideNullable

    nullable was given Some(x) whose inner codec encodes x as null.

  • UnrepresentableNumber(number.Number)

    to_json: the number has no exact gleam/json form.

  • IntegerOutsideRange(minimum: Int, maximum: Int)
  • NumberOutsideRange(
      minimum: number.Number,
      maximum: number.Number,
    )
  • Custom(message: String)

    A message from try_map, decode_failure or encode_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

  • StringSchema

    string(): {"type": "string"}.

  • StringEnumSchema(labels: List(String))

    string_enum: {"type": "string", "enum": labels}.

  • IntSchema

    int(): {"type": "integer"}.

  • IntegerRangeSchema(minimum: Int, maximum: Int)

    integer_between: an integer with inclusive bounds.

  • NumberSchema

    float() and number(): {"type": "number"}.

  • NumberRangeSchema(minimum: number.Number, maximum: number.Number)

    number_between: a number with inclusive bounds.

  • BoolSchema

    bool(): {"type": "boolean"}.

  • PairSchema(left: Schema, right: Schema)

    pair: an array of exactly two items.

  • ListSchema(items: Schema)

    list: an array whose items all match items.

  • NullableSchema(inner: Schema)

    nullable: null or inner.

  • ObjectSchema(properties: List(PropertySchema))

    A record: a closed object.

  • UnionSchema(variants: List(VariantSchema))

    union: a closed {"tag": ..., "value": ...} object, one variant per tag.

  • AnySchema

    value(): 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)

The variants of a union under construction; see union.

pub opaque type Union(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

Values

pub fn bool() -> Codec(Bool)

A JSON boolean.

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 list(of item: Codec(a)) -> Codec(List(a))

A JSON array of items.

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() -> Codec(number.Number)

A JSON number, exactly, as a number.Number.

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 pair(left: Codec(a), right: Codec(b)) -> Codec(#(a, b))

A two-item JSON array.

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(codec: Codec(a)) -> Result(Schema, SchemaError)

The codec’s schema.

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() -> Codec(String)

A JSON string.

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.

✨ Search Document