sinal/fields

Builds the typed codecs that turn a Gleam value into a native telemetry map of measurements or metadata, and back.

Use this module to give a sinal.Event or a sinal/span span its measurement and metadata types. string, int, float and bool each declare one key; enum declares a key whose value is one of a fixed set; optional makes a one-key field optional; field covers any other value with an encoder and a gleam/dynamic/decode decoder. A record codec is a use block: each include adds one field and binds its value by name, and success builds the record. empty declares no keys.

import sinal/fields

pub type Request {
  Request(method: String, status: Int)
}

pub fn request_fields() -> fields.Fields(Request) {
  use method <- fields.include(fields.string("method"), get: fn(r) {
    r.method
  })
  use status <- fields.include(fields.int("status"), get: fn(r) { r.status })
  fields.success(Request(method:, status:))
}

Each value goes to the constructor parameter it is bound to, so the fields may be listed in any order and two fields of one type cannot swap. Each getter is passed with its get: label and needs no type annotation: the rest of the block, which ends in success, fixes the record type first. This is the shape of json/blueprint/codec’s field and success.

Keys are atoms

Each key becomes an atom of the native map, and the BEAM never frees an atom. A key must match [a-z][a-z0-9_]{0,62} and must be written in source code, never built from input. A key that breaks the grammar, a key declared twice in one record, an optional field over anything but one key, and an enum with no values or a repeated name are definition bugs: the constructor panics with a message naming the key.

Decoding

Decoding reads only the declared keys and ignores any others in the map. It fails with a FieldError when the term is not a map, a required key is missing, or a value does not decode. Encoding has no typed error return; custom getters and encoders can still raise. An enum value missing from its list is reported when it is emitted, and check returns the same error to a test (see enum).

Types

Why a native map did not decode. The union is closed: sinal adds no variant without a major release.

pub type FieldError {
  NotAMap
  MissingField(key: String)
  InvalidField(key: String, errors: List(decode.DecodeError))
}

Constructors

  • NotAMap

    The measurements or metadata term was not a map.

  • MissingField(key: String)

    A required key was absent.

  • InvalidField(key: String, errors: List(decode.DecodeError))

    The value at key did not decode; errors come from its decoder.

A codec between a Gleam value and the keys it writes to a native map.

pub opaque type Fields(a)

Values

pub fn bool(key: String) -> Fields(Bool)

Declares a key holding a boolean.

pub fn check(
  fields: Fields(a),
  value: a,
) -> Result(Nil, FieldError)

Reports an enum value missing from its declared list anywhere in fields. It encodes, but a handler using these fields cannot decode it. It returns the InvalidField error the handler would report, and Ok(Nil) for any other value. The emit paths log a warning for the same values; check only returns the error, so a test can assert it.

fields.check(method_field(), Delete)
// -> Error(InvalidField("method", [..])) when Delete is not listed
pub fn decode(
  fields: Fields(a),
  raw: dynamic.Dynamic,
) -> Result(a, FieldError)

Decodes a native map the way a handler would, reading only the declared keys.

pub fn describe_error(error: FieldError) -> String

Describes a decoding failure for logs.

pub fn empty() -> Fields(Nil)

Declares no keys. It decodes any map as Nil and rejects a term that is not a map. It is the same codec as success(Nil).

pub fn encode(fields: Fields(a), value: a) -> dynamic.Dynamic

Encodes value into the native map that sinal.emit would send. Use it to pin a package’s wire format in tests or to hand a map to Erlang code. It writes an enum value missing from its list without complaint; the emit paths report it.

pub fn enum(
  key: String,
  values: List(a),
  name: fn(a) -> String,
) -> Fields(a)

Declares a key holding one of values, written as the UTF-8 binary name(value). Decoding accepts that binary, or an atom with the same text, and fails on any other name.

The compiler checks that name covers every constructor, but it cannot check values: a constructor missing from the list compiles. Encoding still writes its name, and every emit path (sinal.emit, forwarder.emit, span.run) logs a warning in the emitting process that names the event and the value. A handler using this enum fails to decode it, reports MalformedMetadata (or MalformedMeasurements) to its failure observer, skips that one invocation and stays attached when failure reporting returns normally. Those handlers skip the unlisted value until the list is fixed.

So keep values next to the type, and give the application a test that names every constructor and round-trips it through the codec. The compiler cannot list a type’s constructors, so the test names them itself; when it names one that values lacks, check returns the error and the decode fails:

pub fn every_method_is_listed_test() {
  let codec = method_field()
  // One entry per constructor of Method.
  list.each([Get, Post], fn(method) {
    let assert Ok(Nil) = fields.check(codec, method)
    let assert Ok(decoded) =
      fields.decode(codec, fields.encode(codec, method))
    assert decoded == method
  })
}
pub type Method {
  Get
  Post
}

fields.enum("method", [Get, Post], fn(method) {
  case method {
    Get -> "get"
    Post -> "post"
  }
})

Panics when values is empty or two values share a name.

pub fn field(
  key: String,
  encode: fn(a) -> dynamic.Dynamic,
  decoder: decode.Decoder(a),
) -> Fields(a)

Declares one key whose value is written by encode and read by decoder. Use it for a value the other constructors do not cover. Inside a record, sinal runs decoder once against nil where the record is defined, for a placeholder value, so keep it free of effects.

fields.field("attempt", dynamic.int, decode.int)
pub fn float(key: String) -> Fields(Float)

Declares a key holding a float. Decoding also accepts an integer, which native producers often send for a whole-number measurement, and converts it.

pub fn include(
  field: Fields(a),
  then next: fn(a) -> Fields(r),
  get get: fn(r) -> a,
) -> Fields(r)

Adds one field to a record codec and binds its decoded value by name for the rest of the use block, which ends in success:

use status <- fields.include(fields.int("status"), get: fn(r) { r.status })

field writes its keys from the value that get reads from the record. It is usually one key, but it can be another record codec, whose keys are written into the same map. The fields of a record may be listed in any order: each value goes to the constructor parameter it is bound to.

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 compile.

The record’s keys and encoder are fixed once, where the record is defined, by running the use block with placeholder values: the zero value of each field’s decoder (fields.field runs its decoder against nil for it). So keep the block to include calls and a success constructor, and do not choose a field from a value bound before it. Decoding runs the block again with the decoded values.

Panics when field declares a key the rest of the record already has.

pub fn int(key: String) -> Fields(Int)

Declares a key holding an integer.

pub fn keys(fields: Fields(a)) -> List(String)

The keys fields writes, in declaration order.

pub fn optional(inner: Fields(a)) -> Fields(option.Option(a))

Makes a one-key field optional. Encoding None omits the key. Decoding reads a missing key, or the atom nil or undefined that a native producer may write instead, as None; any other value decodes through inner.

Do not wrap an inner whose own encoded values include the atoms nil or undefined: they would decode as None.

Panics when inner declares other than exactly one key.

pub fn string(key: String) -> Fields(String)

Declares a key holding a UTF-8 binary.

pub fn success(value: r) -> Fields(r)

Ends a record codec with the record built from the values that the include calls before it bound:

pub fn request_fields() -> fields.Fields(Request) {
  use method <- fields.include(fields.string("method"), get: fn(r) {
    r.method
  })
  use status <- fields.include(fields.int("status"), get: fn(r) { r.status })
  fields.success(Request(method:, status:))
}

Alone, success(value) declares no keys and decodes any map as value.

✨ Search Document