Gleam OTP

Feb 25, 2023   Kero van Gelder

Erlang is famous for its concurrent processes. On top of that, there is the OTP framework. If we wish to leverage this, or something akin, from Gleam, we need to do some serious adaptation: Gleam is typed at compile-time, whereas a message sent to a process in Erlang is typed at run-time.

We are going to take a common Erlang pattern, and step by step explain how the Gleam equivalent looks, and why. The pattern: send a message to a process, and wait for the response. That is, a synchronous call.

Then we provide a few pointers to build upon this pattern. It's a fairly long post, which tries to bring across some important concepts, so I recommend to sit down with your favourite drink when you read it.

The Erlang version

A simple service that converts an integer to a string:

start() ->
  spawn(fun loop/0).

convert(Convertor, Int) when is_integer(Int) ->
  Convertor ! {req, self(), Int},
  receive
      {resp, Str} -> Str
  end.

loop() ->
  receive
      {req, From, Int} ->
          From ! {resp, integer_to_list(Int) },
          loop()
  end.

Building the Gleam version

There are many details to cover. I'm going to start with the code responsible for sending back the response, and work from there to the making of the request.

First, include the right package: gleam add gleam_erlang

Sending back the response

We have no exclamation mark in the Gleam syntax, it's going to be a regular call.

import gleam/erlang/process.{send}
import gleam/int.{to_string}

pub type Answer {
  Answer(result: String)
}

fn loop() {
  let from = todo
  send(from, Answer(to_string(42)))
  loop()
}

which seems innocuous. But what is the type of send? It cannot be send(proc: Process, message: Answer, because then the next program would not work. You might think it could be send(proc: Process, message: a, but then you could send anything, which is precisely what we want to prevent in Gleam. That brings it to send(proc: Process(msg), message: msg). This is a design pattern you will see more often in languages like Gleam.

The gleam process library does not expose processes. Instead it uses subjects. We will explain more about that when we get to receiving messages. To be complete, we need a return type, which is trivial. We get:

send(subject: Subject(msg), message: msg) -> Nil
In our example, msg is an Answer.

Sending the request

First, we have our process, to which we send a Request of sorts. This request, as we see in the Erlang version, contains two fields.

import gleam/erlang/process.{type Subject, receive, send}

pub type X {
  X
}

pub type Request {
  Request(from: X, query: Int)
}

fn start() {
  todo
  // Provides us with a Subject(Request)
}

pub fn convert(request_subj: Subject(Request), query: Int) {
  send(request_subj, Request(X, query))
}

What is type X? It is the same answer_subj as we have when sending the response, which we already found is a Subject(Answer).

How do we construct the request? There is process.new_subject() -> Subject(a). We cannot pass in a type - but the gleam type checker will infer that a, in our example, is an Answer. If you want to be certain about that, you can always write let s: Subject(Answer) = new_subject(), to force the type checker to tell you when it is not so. This gives us Request(new_subject(), query).

[Added Mar 16] For more information about fn() -> a constructs, see this blog post on phantom types by Hayleigh Thompson.

Receiving the response

Immediately after sending the request, we need to receive the answer.

Just like when we were sending, we need a subject to restrict the type in Gleam. Not only is this an extra argument, compared to the Erlang version, but it has to be of the same type that is passed to send (from the other process, not the line just above). This is important, let it sink in.

The second argument in the API that is exposed to us is a time-out. The function returns Result(msg, Nil), and the error is for the timeout; that makes the full type fn receive(Subject(msg), Int) -> Result(msg, Nil).

! From the docs:

Each subject is “owned” by the process that created it. Any process can use the send function to sent a message of the correct type to the process that owns the subject, and the owner can use the receive function [...] to receive these messages.
This means that the subject we pass to receive has to be the same subject as the one we pass to our service, i.e. that the service passes to send:

import gleam/erlang/process.{type Subject, receive, send}

pub type Request {
  Request(answer_subj: Subject(Answer), query: Int)
}

pub type Answer {
  Answer(result: String)
}

pub fn start() -> Subject(Request) {
  todo
}

pub fn convert(request_subj: Subject(Request), query: Int) {
  let answer_subj: Subject(Answer) = process.new_subject()
  send(request_subj, Request(answer_subj, query))
  let assert Ok(Answer(answer)) = receive(answer_subj, 1234)
  answer
}

This makes a lot more clear that a subject is not a wrapped process; it is two endpoints, one on which you send, and the other on which you receive. Maybe it helps to picture a channel, pipe, or queue.

TIL: it makes it clear that the first arguent to receive is called from, because we receive from a subject/channel, but not the process on the other end.

Receiving the request

We have a subject for the response, and we have a separate subject (channel, pipe, queue) for the request. Separate, because it has a different type.

We need a subject to pass to receive, and the only place to keep it is as the argument of the service loop

import gleam/erlang/process.{type Subject, receive, send}
import gleam/int.{to_string}

pub type Request {
  Request(answer_subj: Subject(Answer), query: Int)
}

pub type Answer {
  Answer(result: String)
}

fn loop(request_subj: Subject(Request)) {
  case receive(request_subj, 1234) {
    Ok(Request(answer_subj, the_int)) ->
      send(answer_subj, Answer(to_string(the_int)))
    Error(Nil) -> Nil
  }
  loop(request_subj)
}

Starting the service

We need to pass the subject to our loop somewhere, don't we? Here it goes:

import gleam/erlang/process.{type Pid, type Subject, receive, send}
import send_request.{type Request}

fn start() -> Subject(Request) {
  let _pid: Pid = process.start(fn() { loop(process.new_subject()) }, False)
  todo
}

fn loop(subject) {
  todo
}

We still need to return a subject. The Pid is rather useless. With all the explanation above you should be able to figure out where the subject is, and how to return it here. Try it, before reading on.

It is the new subject we just created when calling the loop. And we need to send it from the new process to the original process. It is passed to the original process exactly like we send responses.

import gleam/erlang/process.{type Subject, receive, send}
import send_request.{type Request}

type Created {
  Created(Subject(Request))
}

pub fn start() -> Subject(Request) {
  let created_subj: Subject(Created) = process.new_subject()
  let _pid =
    process.start(
      fn() {
        let request_subj: Subject(Request) = process.new_subject()
        send(created_subj, Created(request_subj))
        loop(request_subj)
      },
      False,
    )
  let assert Ok(Created(request_subj)) = receive(created_subj, 1234)
  request_subj
}

fn loop(subject) {
  todo
}

The full program

The above put together, with a main program that makes a synchronous call into our service:

import gleam/erlang/process.{type Subject, receive, send}
import gleam/int.{to_string}
import gleam/io

type Created {
  Created(Subject(Request))
}

pub type Request {
  Request(answer_subj: Subject(Answer), query: Int)
}

pub type Answer {
  Answer(result: String)
}

pub fn main() {
  start()
  |> convert(42)
  |> io.debug
}

pub fn start() -> Subject(Request) {
  let created_subj: Subject(Created) = process.new_subject()
  let _pid =
    process.start(
      fn() {
        let request_subj: Subject(Request) = process.new_subject()
        send(created_subj, Created(request_subj))
        loop(request_subj)
      },
      False,
    )
  let assert Ok(Created(request_subj)) = receive(created_subj, 1234)
  request_subj
}

fn loop(request_subj: Subject(Request)) {
  case receive(request_subj, 1234) {
    Ok(Request(answer_subj, the_int)) ->
      send(answer_subj, Answer(to_string(the_int)))
    Error(Nil) -> Nil
  }
  loop(request_subj)
}

pub fn convert(request_subj: Subject(Request), query: Int) {
  let answer_subj: Subject(Answer) = process.new_subject()
  send(request_subj, Request(answer_subj, query))
  let assert Ok(Answer(answer)) = receive(answer_subj, 1234)
  answer
}

Which gives us:

gleam run
  Compiling simple
   Compiled in 0.05s
    Running simple.main
"42"

That's quite a bit longer than the Erlang original, which is due to the type definitions and the extra setup to get a subject over a subject.

Improvements / Next steps

Note that the explicit response types here are not required for the single arguments that we pass around. Strings (and subjects) are just fine, although they provide less 'typing' which may hurt when you have multiple calls resulting in a string.

Selectors in the process library

When multiple types of messages can be sent to one process, you can use selectors to handle whichever comes in. You need to map (to a single type) inside, so maybe multiple constrctors for your request are just as good.

Helper in the process library

There is process.call(subject: Subject(req), make_request: fn(Subject(response)) -> req, within timeout: Int) -> reponse which hides: the function that you pass as make_request is typically a request constructor.

The OTP actor library

gleam add gleam_otp
import gleam/otp/actor.{call, start}

The start function in here hides the setup, sending and receiving of the created_subj. The call function is just like the one from process.

A codebase to experiment with

[Added Mar 16] Fabjan created this troupe git repo while getting acquainted with Gleam OTP. It provides more code to experiment with than this blogpost does.