distribute

distribute logo

Typed distributed messaging for Gleam on the BEAM.

Package Version Hex Docs

What this is

Erlang already gives you distribution, but from Gleam you lose type info at the node boundary — everything crosses the wire as raw terms. distribute puts binary codecs in front of :global and Subject so the compiler can catch mismatches before messages leave the process.

“Typed” here means checked at encode/decode boundaries. There is no shared type system across nodes — the BEAM doesn’t work that way.

Install

gleam add distribute

Usage

Fire-and-forget

Define a TypedName(msg) that pairs a name with a codec, then use it on both sides. The compiler won’t let you register a String actor and look it up as Int.

import distribute
import distribute/codec
import distribute/registry
import distribute/receiver

// one TypedName, shared across the codebase
let greeter = registry.named("greeter", codec.string())

// start + register
let assert Ok(gs) = distribute.start_actor(greeter, Nil, fn(msg, _state) {
  io.println("Got: " <> msg)
  receiver.Continue(Nil)
})
let assert Ok(Nil) = distribute.register(greeter, gs)

// from any node
let assert Ok(remote) = distribute.lookup(greeter)
let assert Ok(Nil) = distribute.send(remote, "hello")

Request / response

Include a reply Subject(BitArray) in your message type and use codec.subject() to serialize it. The Subject carries node info, so replies route back across nodes automatically.

import distribute/codec
import distribute/global
import distribute/receiver
import gleam/erlang/process

type CounterMsg {
  Inc(Int)
  Get(reply: process.Subject(BitArray))
}

// handler side
fn handle(msg, state) {
  case msg {
    Inc(n) -> receiver.Continue(state + n)
    Get(reply) -> {
      let _ = global.reply(reply, state, codec.int_encoder())
      receiver.Continue(state)
    }
  }
}

// caller side
let assert Ok(count) = global.call(counter, Get, codec.int_decoder(), 5000)

global.call creates a temporary subject, sends the request, waits for the response, decodes it. Same idea as gen_server:call.

Codecs

Primitives: codec.int(), codec.string(), codec.float(), codec.bool(), codec.bitarray(), codec.nil().

Composites: codec.list(c), codec.subject(), codec.map(c, wrap, unwrap), composite.option(c), composite.result(ok, err), composite.tuple2(a, b), composite.tuple3(a, b, c).

For your own types, use codec.map:

type UserId { UserId(Int) }

let user_id_codec = codec.map(codec.int(), UserId, fn(uid) {
  let UserId(n) = uid
  n
})

Gleam has no derive macros or reflection, so codecs for complex types are manual. The combinators handle the serialization — you just wire the fields together.

Modules

ModuleDoes
distributeFacade — start node, connect, send, lookup
distribute/actorNamed actors, supervision, pools
distribute/clusternet_kernel start/connect/ping
distribute/codecBinary codecs for primitives + subject()
distribute/codec/compositeOption, Result, Tuple codecs
distribute/codec/taggedTagged messages with version field
distribute/globalGlobalSubject(msg), call, reply
distribute/registryTypedName(msg), :global registration
distribute/receiverTyped receive, OTP actor wrappers

Caveats

What the types catch — within one codebase, TypedName and GlobalSubject prevent mixing up message types at compile time.

What they don’t — two separate codebases using different codecs for the same name. The codec will reject the binary at runtime, not at compile time. Same for Erlang code sending raw terms to a distribute actor.

Subject construction — Gleam’s Subject is opaque. To build one from a remote PID and a deterministic tag (how registry lookup works), we construct the {subject, Pid, Tag} tuple in one Erlang function. If gleam_erlang changes the internal representation, that single function needs updating.

No auto-derive — Gleam doesn’t have macros. Complex message codecs are manual. The combinators (map, list, option, tuple2, etc.) keep it manageable, but it’s not zero-boilerplate.

Development

gleam test
gleam docs build
Search Document