Gleam: The Type-Safe Language on BEAM
TOP 5 Jan. 1, 2026, 11:30 a.m.

Gleam: The Type-Safe Language on BEAM

Gleam is a modern, statically‑typed functional language that runs on the BEAM VM – the same virtual machine powering Erlang and Elixir. It brings the safety of a strong type system to the world of concurrent, fault‑tolerant applications, while still letting you tap into the massive ecosystem of OTP libraries. In this article we’ll explore why Gleam feels familiar to Elixir developers, how its type system prevents whole classes of bugs, and walk through a couple of real‑world examples you can run today.

Why Choose Gleam on the BEAM?

First, the BEAM gives you lightweight processes, message passing, and hot code upgrades out of the box. Gleam inherits all of that without forcing you into a particular runtime model. Second, Gleam’s compiler catches mismatched types, missing fields, and pattern‑matching errors before the code ever hits the VM. This eliminates a large chunk of runtime crashes that are common in dynamically‑typed BEAM languages.

Third, Gleam’s syntax is deliberately minimal: pattern matching, algebraic data types, and pipe‑forward operators are all present, but there are no macros or metaprogramming tricks that can obscure what the code actually does. Finally, Gleam compiles to both Erlang bytecode and JavaScript, so you can share business logic between a server and a browser‑based UI.

Key Benefits at a Glance

  • Type safety: The compiler guarantees that functions receive and return the exact shapes you declare.
  • Zero‑runtime overhead: Types are erased during compilation, so the generated BEAM code is as fast as native Erlang.
  • Interoperability: Call any Erlang or Elixir module directly, and expose Gleam functions to the rest of your BEAM ecosystem.
  • Predictable concurrency: Leverage OTP behaviours (GenServer, Supervisor, etc.) without sacrificing type guarantees.

Getting Started: The Minimal Project

Let’s spin up a fresh Gleam project with the official CLI. Open a terminal and run:

gleam new chat_server
cd chat_server
gleam build

The scaffold creates a src directory with a chat_server.gleam module and a gleam.toml configuration file. Running gleam build compiles the project to build/dev/erlang, ready to be started with erl -pa build/dev/erlang.

Now that the basics are in place, let’s dive into a concrete example: a simple, type‑safe chat server built on top of OTP’s gen_server behaviour.

Example 1: A Type‑Safe Chat Server

We’ll model three core concepts: a User struct, a Message enum, and a ChatRoom state that tracks connected users and a message history. By defining these types up front, the compiler will prevent us from accidentally sending a malformed message or referencing a non‑existent user.

Defining the Domain Types

pub type User = {
  id: Int,
  name: String,
}

pub type Message =
  | Text(String)
  | Join(User)
  | Leave(User)

pub type ChatState = {
  users: List(User),
  history: List(Message),
}

Notice how Message is an algebraic data type (ADT) with three distinct variants. Pattern matching on Message later will be exhaustive, meaning the compiler forces us to handle every case.

Implementing the GenServer

Gleam ships with a thin wrapper around Erlang’s gen_server. The module below implements the required callbacks: init, handle_call, and handle_cast. All messages are typed, so you’ll see the compiler reject any mismatched payload.

import gleam/erlang
import gleam/list.{map}
import gleam/io

pub fn start() -> Nil {
  erlang.gen_server.start_link(
    #{
      name: "chat_server",
      init: fn() -> {ok, empty_state()},
      handle_call: handle_call,
      handle_cast: handle_cast,
    }
  )
  Nil
}

fn empty_state() -> ChatState {
  #{ users: [], history: [] }
}

fn handle_call(
  request: Message,
  _from: erlang.Pid,
  state: ChatState,
) -> {reply, Nil, ChatState} {
  case request {
    Text(text) -> {
      let new_history = [Text(text) | state.history]
      let new_state = #{ state | history: new_history }
      {reply, Nil, new_state}
    }
    Join(user) -> {
      let new_users = [user | state.users]
      let new_state = #{ state | users: new_users }
      io.println("User #{user.name} joined")
      {reply, Nil, new_state}
    }
    Leave(user) -> {
      let new_users = list.filter(state.users, fn(u) -> u.id != user.id end)
      let new_state = #{ state | users: new_users }
      io.println("User #{user.name} left")
      {reply, Nil, new_state}
    }
  }
}

fn handle_cast(_msg: Nil, state: ChatState) -> {noreply, ChatState} {
  {noreply, state}
}

Because handle_call returns a tuple with a concrete type ({reply, Nil, ChatState}), any deviation – such as returning a plain ChatState – will be caught during compilation.

Running the Server

Compile and start the server from the REPL:

gleam repl
> import chat_server
> chat_server.start()
> erlang.gen_server.call("chat_server", Join(#{
      id: 1,
      name: "Alice"
    }))
> erlang.gen_server.call("chat_server", Text("Hello, world!"))

When you send a Join or Text message, the server updates its internal state and prints a log line. Try sending a malformed payload (e.g., a plain string) – the REPL will refuse to compile, demonstrating Gleam’s safety net.

Pro tip: Keep your public API surface small. Export only the functions you need for external callers, and hide the rest behind private modules. This reduces the chance of accidental misuse and makes the type contracts clearer.

Example 2: HTTP API with Cowboy and Gleam

Gleam isn’t limited to OTP behaviours; you can also build web services. In this example we’ll expose a tiny JSON API that returns the current chat history. We’ll use Cowboy as the HTTP server, which Gleam can call directly via Erlang interop.

Adding Dependencies

Open gleam.toml and add the following under [dependencies]:

[dependencies]
gleam_erlang = "0.22"
gleam_json = "0.5"
cowboy = "2.10"

Run gleam deps download to fetch the libraries. The gleam_json package provides encode/decode helpers that work seamlessly with Gleam structs.

Serialising the ChatState

We’ll implement a to_json function that converts ChatState into a JSON‑compatible map. Gleam’s type system ensures we only pass serialisable values.

import gleam/json.{encode, value}
import gleam/list.{map}
import gleam/erlang

pub fn state_to_json(state: ChatState) -> value {
  let messages = map(state.history, fn(msg) {
    case msg {
      Text(t) -> #{
        type: "text",
        payload: t
      }
      Join(u) -> #{
        type: "join",
        user: user_to_json(u)
      }
      Leave(u) -> #{
        type: "leave",
        user: user_to_json(u)
      }
    }
  })

  #{
    users: map(state.users, &user_to_json/1),
    history: messages,
  }
}

fn user_to_json(user: User) -> value {
  #{
    id: user.id,
    name: user.name,
  }
}

Notice how each branch of the case returns a map with the same shape – the compiler enforces consistency, preventing accidental omission of a field.

Wiring Up Cowboy

Now we’ll start Cowboy and define a single route /history that returns the JSON representation of the chat state.

pub fn start_http(state: ChatState) -> Nil {
  let dispatch = [
    {
      method: "GET",
      path: "/history",
      handler: fn(_req) {
        let body = encode(state_to_json(state))
        erlang.cowboy_req.reply(200, #{
          <<"content-type">>: <<"application/json">>
        }, body, _req)
      }
    }
  ]

  let _ = erlang.cowboy.start_clear(
    #{
      port: 4000,
      env: #{
        dispatch: dispatch
      }
    }
  )
  Nil
}

Because state is captured in the closure, the HTTP handler always sees the latest chat state. In a production system you’d likely store the state in an ETS table or a GenServer, but this example keeps things straightforward.

Testing the Endpoint

Run the application, then use curl to fetch the history:

gleam run
# In another terminal
curl http://localhost:4000/history

You should see a JSON payload similar to:

{
  "users": [
    {"id":1,"name":"Alice"}
  ],
  "history": [
    {"type":"join","user":{"id":1,"name":"Alice"}},
    {"type":"text","payload":"Hello, world!"}
  ]
}
Pro tip: When exposing data over HTTP, prefer explicit encoding functions (like state_to_json) over generic serializers. This makes the contract visible in code and avoids accidental leakage of internal fields.

Real‑World Use Cases for Gleam

Gleam shines wherever you need reliability at scale. Below are three domains where its type safety and BEAM heritage provide a tangible advantage.

  1. Financial transaction processing: Strict types prevent mismatched currency codes, overflow errors, and malformed payloads. Combined with OTP supervisors, you get a system that can self‑heal without risking data corruption.
  2. IoT device orchestration: Lightweight BEAM processes map naturally to thousands of sensor connections. Gleam’s immutable data structures simplify reasoning about state changes across a distributed fleet.
  3. Real‑time collaborative apps: Chat, collaborative editors, and multiplayer games benefit from Erlang’s message passing. Gleam guarantees that messages conform to a predefined schema, reducing desynchronisation bugs.

In each scenario, the compiler acts as a first line of defense, catching bugs that would otherwise surface only under load or after a deployment.

Tooling, Testing, and Debugging

Gleam ships with a built‑in test runner that integrates with gleam test. Tests are written in Gleam itself, allowing you to assert on types directly. Here’s a quick unit test for the state_to_json function:

import gleam_test.{test, assert_eq}
import gleam/json.{decode}
import chat_server.{User, Message, ChatState, state_to_json}

test "state_to_json produces expected JSON" {
  let user = #{
    id: 42,
    name: "Bob"
  }

  let state = #{
    users: [user],
    history: [Join(user), Text("Hey!")]
  }

  let json = state_to_json(state)
  let expected = #{
    users: [
      #{id: 42, name: "Bob"}
    ],
    history: [
      #{
        type: "join",
        user: #{id: 42, name: "Bob"}
      },
      #{
        type: "text",
        payload: "Hey!"
      }
    ]
  }

  assert_eq(json, expected)
}

The test compiles only if state_to_json returns a value that matches the shape of expected. This eliminates a whole class of runtime mismatches that are common in JavaScript‑based testing.

For debugging, you can fall back to Erlang’s :observer.start() to inspect process trees, message queues, and memory usage. Since Gleam code compiles to Erlang modules, all standard OTP tools work out of the box.

Pro tip: Keep your Gleam modules small (under 200 lines). Small modules make pattern matching exhaustive checks faster and keep compilation times low, which is especially helpful when you have a large codebase.

Interoperability with Existing BEAM Codebases

One of Gleam’s biggest selling points is its seamless interop with Erlang and Elixir. You can call any Erlang function using the erlang. module namespace, and you can expose Gleam functions to be called from Elixir with the @external annotation.

Calling Erlang from Gleam

import erlang.{self, send, receive, after}

// Send a message to a PID
pub fn ping(pid: erlang.Pid) -> Nil {
  send(pid, #"ping")
  Nil
}

// Receive a reply with a timeout
pub fn await_pong() -> Result(String, String) {
  receive {
    #"pong" -> Ok("Got pong")
  } after 5000 -> Err("Timed out waiting for pong")
}

Because the receive block must cover all possible messages (or provide a timeout), the compiler warns you if you forget to handle a case, preventing silent message drops.

Calling Gleam from Elixir

Suppose you have an Elixir Phoenix app that wants to use the Gleam chat logic. Add the compiled Gleam module to your mix.exs path, then call it like any other Erlang module:

# In Elixir
{:ok, _} = :chat_server.start()
:chat_server.handle_call({:join, %{id: 2, name: "Eve"}}, self(), state)

The Elixir code sees the same type guarantees because the underlying BEAM bytecode respects the contracts you defined in Gleam.

Performance Considerations

Gleam’s compiled output is essentially Erlang code, so its performance characteristics match those of native Erlang/Elixir. However, there are a few idioms that keep your Gleam programs fast:

  • Avoid deep recursion on large lists: Use tail‑recursive functions or the built‑in list.map which is optimized for the BEAM.
  • Prefer immutable data structures: While immutability is the default, large nested updates can allocate many intermediate structures. Use the record update syntax (#{record | field: new}) to keep allocations minimal.
  • Leverage ETS for shared state: If you need mutable, concurrent access to a large dataset, store it in an Erlang Term Storage (ETS) table and wrap access in Gleam functions.

Benchmarks show that a pure Gleam implementation of a simple key‑value store can handle tens of thousands of requests per second on a single BEAM VM, comparable to an equivalent Elixir implementation.

Pro tip: When you hit
Share this article