JSON Wire Protocol

JSON is Libero’s readable wire protocol for SDKs, tools, logs, fixtures, and clients that should not need an ETF implementation. It uses the same typed contract as ETF, but carries type identity as readable JSON fields.

JSON serializes Libero request envelopes, responses, pushes, and SSR flags. It is not a REST resource format.

The canonical typed value contract is named typed-json-v1. Every JSON transport path that carries a user value uses this contract:

Transport envelopes add routing, request IDs, protocol versions, and contract hashes. They do not define private value dialects. Values inside those envelopes must be produced by generated JSON encoders and consumed by generated JSON decoders for typed-json-v1.

Pros And Cons

JSON is a good fit when values need to be readable outside the BEAM or consumed by generated SDKs and tools.

Pros:

Cons:

Request Envelope

{
  "kind": "request",
  "protocol_version": "libero-json-v1",
  "contract_hash": "example-contract-hash",
  "module": "public/pages/article",
  "request_id": 1,
  "message": {
    "type": "shared/messages.MsgFromClient",
    "variant": "GetArticle",
    "fields": {
      "slug": "hello-world"
    }
  }
}

Rules:

The contract hash is a fail-fast compatibility check. If client and server were generated from different contracts, the server can reject the request before it tries to decode the message.

Response Envelope

{
  "kind": "response",
  "protocol_version": "libero-json-v1",
  "request_id": 1,
  "value": {
    "type": "gleam/result.Result",
    "variant": "Ok",
    "fields": [
      {
        "type": "shared/article.Article",
        "variant": "Loaded",
        "fields": {
          "title": "Hello",
          "body": "..."
        }
      }
    ]
  }
}

The request_id matches the original request. The value is the generated response value for that handler.

Error Envelope

{
  "kind": "error",
  "protocol_version": "libero-json-v1",
  "request_id": 1,
  "errors": [
    {
      "path": "message.fields.slug",
      "message": "expected String, got Null"
    }
  ]
}

Errors are protocol errors, not handler domain errors. Decoders report paths so callers can show useful diagnostics or logs.

Push Envelope

{
  "kind": "push",
  "protocol_version": "libero-json-v1",
  "module": "public/pages/article",
  "value": {
    "type": "public/pages/article.ToClient",
    "variant": "CommentsUpdated",
    "fields": {
      "comments": []
    }
  }
}

Push values pass through generated typed encoders before they are wrapped in the protocol envelope.

Typed Values

typed-json-v1 is the only JSON representation for generated Libero user values. Custom types use a readable object shape:

{
  "type": "module/path.TypeName",
  "variant": "VariantName",
  "fields": {}
}

type is the source module path plus type name. variant is the constructor name. fields contains the constructor fields.

Labelled fields use an object:

pub type Article {
  Article(title: String, body: String)
}
{
  "type": "shared/article.Article",
  "variant": "Article",
  "fields": {
    "title": "Hello",
    "body": "..."
  }
}

Unlabelled fields use an array in declaration order:

pub type Pair {
  Pair(String, Int)
}
{
  "type": "shared/pair.Pair",
  "variant": "Pair",
  "fields": ["count", 2]
}

Zero-field variants use an empty object:

{
  "type": "shared/status.Status",
  "variant": "Ready",
  "fields": {}
}

Constructors with mixed labelled and unlabelled fields are rejected by JSON codegen. That keeps the public JSON shape explainable and avoids a hybrid object/array field format.

How JSON Preserves Uniqueness

JSON uses readable identity instead of ETF’s compact hashes. A constructor name alone is never enough. The decoder validates the surrounding type, variant, and field contract before constructing a value.

This means two modules can both define Loaded, and they stay distinct:

{
  "type": "pages/home.State",
  "variant": "Loaded",
  "fields": { "count": 1 }
}
{
  "type": "pages/admin.State",
  "variant": "Loaded",
  "fields": { "count": 1 }
}

Generated decoders enter through an expected type or contract artifact lookup. They do not guess from object shape, constructor name, or arity.

For the shared identity rules behind both ETF and JSON, see Wire Type Identity.

Built-In Shapes

Gleam typeJSON shapeNotes
StringJSON stringUnicode text.
BoolJSON booleantrue or false.
IntJSON integerMust be within JavaScript safe integer range.
FloatJSON numberNaN, Infinity, and -Infinity are rejected.
NilnullNo other sentinel value is accepted.
List(a)JSON arrayElements use their own typed JSON shape.
Dict(String, a)JSON objectValues use their own typed JSON shape.
Dict(Int, a)JSON array of two-item arraysKeys stay numbers, not strings.
Dict(Bool, a)JSON array of two-item arraysKeys stay booleans, not strings.
#(...) tupleJSON arrayPositional values in tuple order.
BitArrayTagged object{ "encoding": "base64url", "data": "..." }.
Option(a)Typed custom shapegleam/option.Option with variants Some and None.
Result(a, e)Typed custom shapegleam/result.Result with variants Ok and Error.
User custom typeTyped custom shapetype, variant, and fields.

Option(a) does not use null as a shortcut. None and Some(None) are different Gleam values, so JSON keeps them distinct.

Dict keys must be String, Int, or Bool. Other key types are rejected during generation because JSON cannot preserve their identity without inventing a lossy stringification rule.

BitArray values use an explicit object shape so binary data cannot be confused with ordinary strings:

{
  "encoding": "base64url",
  "data": "AAECAw=="
}

Tuple values use JSON arrays in tuple order:

["count", 2]

Nested user types always route through their own generated encoder or decoder, even when they appear inside List, Dict, tuple, Option, Result, or another custom type.

Unsupported Shapes

JSON generation fails before runtime for shapes that cannot be represented by typed-json-v1 without losing type information:

Tuple support is currently positional. If that changes, the typed_value_contract name must change and snapshots must be updated.

Contract Artifact

The generated contract.json artifact records the JSON protocol and the typed value contract:

{
  "contract_hash": "...",
  "protocol_version": "libero-json-v1",
  "typed_value_contract": "typed-json-v1",
  "libero_version": "7.0.0",
  "push_types": [],
  "ssr_models": [],
  "types": []
}

contract_hash is computed from the canonical artifact fields without the hash field itself. It covers push types, SSR models, reachable user types, the JSON protocol version, and the typed value contract name. A request whose hash does not match the server contract is rejected before message decode.

Validation

Generated JSON decoders validate before constructing typed values:

Generated JSON encoders sit on the trusted side of the boundary. They receive typed application values after your program has already constructed them. If an encoder sees an Int outside the JavaScript safe integer range or a non-finite Float, it panics instead of emitting JSON that the decoder would later reject. Those values should only be possible through FFI or unsafe construction.

Errors include a path and message:

message.fields.slug: expected String, got Null
value.fields.comments[3].fields.author.fields.id: expected Int, got String

Security Limits

JSON decoding should apply limits before or during decode:

JavaScript decoders must avoid prototype mutation hazards. Protocol-owned objects reject unsafe field names such as __proto__, prototype, and constructor. User data in Dict(String, a) must still round-trip those strings as data without assigning them as object prototype properties.

Protocol Helpers

JSON helpers live in libero/json/wire.

The contract-level helper surface is:

ConceptHelper
Encode an outbound requestencode_request
Decode an inbound requestdecode_request
Encode a response frameencode_response
Decode a server framedecode_server_frame
Encode a push frameencode_push
Encode SSR flagsencode_flags
Decode SSR flagsdecode_flags_typed
Search Document