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 is an RPC protocol. It serializes Libero requests, responses, pushes, and SSR flags. It is not a REST resource format.

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": "json-rpc-v1",
  "contract_hash": "example-contract-hash",
  "module": "rpc",
  "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": "json-rpc-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": "json-rpc-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": "json-rpc-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

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 shape
StringJSON string
BoolJSON boolean
IntJSON integer within JavaScript safe integer range
FloatJSON number, excluding NaN, Infinity, and -Infinity
Nilnull
List(a)JSON array
Dict(String, a)JSON object
Dict(k, v) for non-string kJSON array of two-item arrays
TupleJSON array in tuple order
BitArrayBase64 string with standard padding
Option(a)Typed custom shape with variants Some and None
Result(a, e)Typed custom shape with variants Ok and Error

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

Validation

Generated JSON decoders validate before constructing typed values:

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