Glixir 🌟

Package Version Hex Docs

A Safe(ish) OTP interop between Gleam and Elixir/Erlang

Bridge the gap between Gleam’s type safety and the battle-tested OTP ecosystem. Use GenServers, Supervisors, Agents, Registry, and more from Gleam with confidence.

Features


Type Safety & Phantom Types

A note from your neighborhood type enthusiast:

Some glixir APIs (notably process calls, GenServer, Registry) still require passing Dynamic values—this is the price of seamless BEAM interop and runtime dynamism. While decoders on return values help catch mismatches, full compile-time type safety isn’t always possible… yet.

But here’s the good news: We’re actively rolling out phantom types and generics across the API, banishing Dynamic wherever possible and making misused actors a compile-time relic.


Type safety level

GenServer:     [■■■■■■■■□□] 80% - Request/reply types enforced by Gleam, decoder required, but runtime BEAM interop can still fail if types disagree.
Supervisor:    [■■■■■■■■■□] 90% - Phantom-typed with compile-time child spec validation, args/replies bounded by generics.
Registry:      [■■■■■■■■■□] 90% - Phantom-typed with compile-time key/message validation, requires key encoders.
Agent:         [■■■■■■■■■■] 100% - State and API fully generic and type safe!
PubSub:        [■■■■■■■■□□] 80% - JSON-based type safety with user-defined encoders/decoders. Phantom-typed for message_type.
Task:          [□□□□□□□□□□] 0% - Not started.

Full green bars are the dream; until then, decoders are your seatbelt!


Why Not 100% Type-Safe Now?

Blame Erlang! (Just kidding, blame dynamic process boundaries.) You get strict safety inside Gleam, but as soon as you jump the BEAM-to-BEAM fence, the runtime is your playground. So, until Gleam’s type system can tame Elixir’s wild world, we work with decoders and are pushing hard to get you closer to total type bliss.


⚠️ Caveats: BEAM Interop Trade-offs

Glixir provides bounded type safety - the maximum safety possible while maintaining full OTP functionality. Some limitations are inherent to BEAM interop, not design flaws:

The Safety Spectrum

Unavoidable BEAM Realities

What You Get

Compile-time: Prevents category errors, type mixing, wrong message types
⚠️ Runtime: Process existence, module validity, message format compatibility

Bottom Line: Glixir is the sweet spot - maximum practical safety with essential functionality that core Gleam simply doesn’t provide.

Want to help speed this up? File issues, suggest API improvements, or just cheer us on in the repo!


⚡ Atom Safety & Fail-Fast API

glixir now requires all process, registry, and module names to be existing atoms—typos or missing modules crash immediately with {bad_atom, ...}.

This makes glixir safer by default—don’t trust user input as atom names!


Installation

gleam add glixir

Add the Elixir helper modules to your project:

# lib/glixir_supervisor.ex and lib/glixir_registry.ex
# Copy the helper modules from the docs

Quick Start

PubSub - Type-Safe Distributed Messaging

Real-time event broadcasting with JSON-based type safety! 📡

import glixir
import gleam/json
import gleam/dynamic/decode
import gleam/erlang/atom

// Define your message types
pub type UserEvent {
  PageView(user_id: String, page: String)
  Purchase(user_id: String, amount: Float)
}

// Create encoder/decoder for type safety
pub fn encode_user_event(event: UserEvent) -> String {
  case event {
    PageView(user_id, page) -> 
      json.object([
        #("type", json.string("page_view")),
        #("user_id", json.string(user_id)),
        #("page", json.string(page))
      ]) |> json.to_string
    
    Purchase(user_id, amount) ->
      json.object([
        #("type", json.string("purchase")),
        #("user_id", json.string(user_id)),
        #("amount", json.float(amount))
      ]) |> json.to_string
  }
}

pub fn decode_user_event(json_string: String) -> Result(UserEvent, String) {
  // Your custom decoder logic here
  json.decode(json_string, dynamic.decode3(
    PageView,
    dynamic.field("user_id", decode.string),
    dynamic.field("page", decode.string),
    dynamic.field("type", decode.string)
  ))
}

// Handler function for PubSub messages
pub fn handle_user_events(json_message: String) -> Nil {
  case decode_user_event(json_message) {
    Ok(PageView(user_id, page)) -> {
      io.println("User " <> user_id <> " viewed " <> page)
    }
    Ok(Purchase(user_id, amount)) -> {
      io.println("User " <> user_id <> " purchased $" <> float.to_string(amount))
    }
    Error(_) -> {
      io.println("Failed to decode user event")
    }
  }
}

pub fn pubsub_example() {
  // Start a phantom-typed PubSub system
  let assert Ok(_pubsub: glixir.PubSub(UserEvent)) = 
    glixir.pubsub_start(atom.create("user_events"))
  
  // Subscribe with your handler function
  let assert Ok(_) = glixir.pubsub_subscribe(
    atom.create("user_events"),
    "user:metrics",
    "my_app",  // Your module name
    "handle_user_events"  // Your handler function
  )
  
  // Broadcast type-safe events from anywhere
  let page_event = PageView("user_123", "/dashboard")
  let assert Ok(_) = glixir.pubsub_broadcast(
    atom.create("user_events"),
    "user:metrics", 
    page_event,
    encode_user_event  // Your encoder
  )
  
  let purchase_event = Purchase("user_123", 99.99)
  let assert Ok(_) = glixir.pubsub_broadcast(
    atom.create("user_events"),
    "user:metrics",
    purchase_event,
    encode_user_event
  )
  
  // Cleanup when done
  let assert Ok(_) = glixir.pubsub_unsubscribe(
    atom.create("user_events"), 
    "user:metrics"
  )
}

Direct Actor Targeting Pattern:

// Perfect for metric actors that need their own identity
pub fn handle_metric_update(actor_id: String, json_message: String) -> Nil {
  case decode_metric_message(json_message) {
    Ok(metric) -> {
      // Update this specific actor's metrics
      io.println("Actor " <> actor_id <> " received metric: " <> metric.name)
      // ... update actor state
    }
    Error(_) -> {
      io.println("Invalid metric for actor: " <> actor_id)
    }
  }
}

// Subscribe each metric actor with its own ID let assert Ok() = glixir.pubsub_subscribe_with_registry_key( atom.create(“metrics_pubsub”), “metric:updates”, “my_app”, “handle_metric_update”, “metric_actor” <> actor_id // Each actor gets its own key )

Multi-Message Handler Example:

// Single handler can process multiple message types!
pub fn handle_all_events(json_message: String) -> Nil {
  case decode_user_event(json_message) {
    Ok(user_event) -> handle_user_event(user_event)
    Error(_) -> 
      case decode_system_event(json_message) {
        Ok(system_event) -> handle_system_event(system_event)
        Error(_) -> io.println("Unknown message type: " <> json_message)
      }
  }
}

Agent State Management (Now Type Safe!)

import glixir
import gleam/dynamic/decode
import gleam/erlang/atom

pub fn main() {
  // Start an agent with initial state (Agent(Int))
  let assert Ok(counter) = glixir.start_agent(fn() { 42 })

  // Get state with a decoder (type safe!)
  let assert Ok(value) = glixir.get_agent(counter, fn(x) { x }, decode.int)
  io.debug(value)  // 42

  // Update state
  let assert Ok(_) = glixir.update_agent(counter, fn(n) { n + 10 })

  // Get and update in one operation
  let assert Ok(old_value) = glixir.get_and_update_agent(
    counter,
    fn(n) { #(n, n * 2) },
    decode.int
  )
  io.debug(old_value)  // 52

  // Stop the agent with a reason (now explicit)
  let assert Ok(_) = glixir.stop_agent(counter, atom.create("normal"))
}

Registry - Dynamic Actor Discovery

import glixir
import gleam/erlang/process
import gleam/erlang/atom
import gleam/dynamic
import gleam/io

pub type UserMessage {
  RecordMetric(name: String, value: Float)
  GetStats
}

pub fn actor_discovery_example() {
  // Start phantom-typed registry for actor lookup
  let assert Ok(_registry: glixir.Registry(atom.Atom, UserMessage)) = 
    glixir.start_registry(atom.create("user_actors"))
  
  // Start a type-safe dynamic supervisor
  let assert Ok(supervisor) = glixir.start_dynamic_supervisor_named(
    atom.create("user_supervisor")
  )
  
  // String encoder for user ID args
  fn user_id_encode(user_id: String) -> List(dynamic.Dynamic) {
    [dynamic.string(user_id)]
  }
  
  // Simple decoder for replies
  fn simple_decode(_d: dynamic.Dynamic) -> Result(String, String) {
    Ok("started")
  }
  
  // Create a type-safe child spec
  let user_spec = glixir.child_spec(
    id: "user_123",
    module: "MyApp.UserActor", 
    function: "start_link",
    args: "user_123",  // Typed as String!
    restart: glixir.permanent,
    shutdown_timeout: 5000,
    child_type: glixir.worker,
    encode: user_id_encode,
  )
  
  // Start the child with compile-time type safety
  case glixir.start_dynamic_child(supervisor, user_spec, user_id_encode, simple_decode) {
    glixir.ChildStarted(_user_pid, _reply) -> {
      // Actor registers itself in the registry with typed key
      let user_subject = process.new_subject()
      let assert Ok(_) = glixir.register_subject(
        atom.create("user_actors"), 
        atom.create("user_123"),  // Typed Atom key
        user_subject,
        glixir.atom_key_encoder   // Required encoder
      )
      
      // Later... find and message the actor from anywhere!
      case glixir.lookup_subject(
        atom.create("user_actors"), 
        atom.create("user_123"),
        glixir.atom_key_encoder
      ) {
        Ok(subject) -> {
          process.send(subject, RecordMetric("page_views", 1.0))
          io.println("Metric sent to user actor! 📊")
        }
        Error(_) -> io.println("User actor not found")
      }
    }
    glixir.StartChildError(error) -> {
      io.println("Failed to start user actor: " <> error)
    }
  }
}

GenServer Interop

import glixir
import gleam/erlang/atom
import gleam/dynamic
import gleam/dynamic/decode

// Example: Counter server (request type is atom, reply type is Int)
pub fn main() {
  let assert Ok(server) = glixir.start_genserver("MyApp.Counter", dynamic.int(0))
  let assert Ok(count) = glixir.call_genserver(server, atom.create("get_count"), decode.int)
  io.debug(count)  // 0

  let assert Ok(_) = glixir.cast_genserver(server, atom.create("increment"))

  let assert Ok(count) = glixir.call_genserver(server, atom.create("get_count"), decode.int)
  io.debug(count)  // 1
}

Type Safety Notes

While this library provides type-safe wrappers on the Gleam side, remember:

  1. Runtime Types: Elixir/Erlang processes are dynamically typed at runtime
  2. Message Contracts: Ensure message formats match between Gleam and Elixir
  3. JSON Serialization: PubSub uses JSON for cross-process type safety
  4. Decoder Functions: Always provide appropriate decoders for return values
  5. Error Handling: Handle all potential decode and process errors
  6. Testing: Test integration points thoroughly

Real-World Use Cases

TrackTags - Auto-scaling metrics platform built with glixir:


About the Author

Built by Rahmi Pruitt - Ex-Twitch/Amazon Engineer turned indie hacker, on a mission to bring Gleam to the mainstream! 🚀

“Making concurrent programming delightful, one type at a time.”


⭐ Star this repo if glixir helped you build something awesome! Your support helps bring mature OTP tooling to the Gleam ecosystem.

Search Document