A staticly typed API for sending/receiving messages. (In Gleam)

Disclaimer: I’m totally out of my depth with this suggestion, I’ve merely recently learned about this while listening to the corecursive podcast episode about Idris.

This feels like something “dependent types” could solve. After all the response depends on the value of the request. I might be totally off base here - like I said, I’m definitely not an expert on the subject - but from my naive perspective it feels like a good fit.

1 Like

Yes, this is a difficult problem. It might be the case that to do this would require record subtyping or full-fledged dependent typing, but I of course hope to find some kind of middle ground where we can think of something that is both practical and not super difficult to implement.

Erlang/OTP puts together all clauses for a handle_* even though they expect widely different input/output-values, and gets away with it because of its dynamic nature. :thinking:

The current plan is to dodge that issue by not having the handler function return the message. Once you adopt a different pattern it can be typed much more easily!

Wouldn’t this make testing more involved? I quite like that I can write unit tests on my pure handler functions in Elixir.


I wonder if it would be feasible to introduce “partial unions”. What I mean with that should be apparent from the following code snippet:

type MyUnion {
  A
  B
  C
}

fn my_function(number) {
  case number {
    0 -> A
    n -> B
  }
}

While the example is ridiculously arbitrary, it should get the point across: my_function only returns A and B of the union type MyUnion. In theory the compiler could infer this from the code and then use that information downstream.

This would then allow the compiler to reason about this code more in depth:

fn another_function(number) {
  case my_function(number) {
    A -> "it's A"
    B -> "it's B"
    // can never be reached
    C -> "it's C"
  }
}

The same reasoning could then be used to trim down on the need to handle all variations of a response type from a GenServer like construct.

Does this make sense?

That would be possible with row types, but I’m not sure how that would solve the problem at hand here- the type system still can’t tell you which reply is given for each message. For this reason I think other patterns are more suitable.

As for testing I would encourage you to extract business logic into pure functions rather than coupling it to a process, and that goes for with Elixir and Erlang too!

4 Likes

I did some more thinking.

One could view the problem of supporting handle_call-style (that is: synchronious) message-passing also as follows I believe this technique might be known as ‘reified existential typeclasses’:

  • From the point of the caller, we have a ‘wrapper’ function fn(a) -> b, or, to be exact fn(a) -> GenServerResult(b) where this GenServerResult might also indicate e.g. that the server does not exist, that we received a timeout, etc.
  • From the point of the consumer, each different clause of handle_call (when treating it as Erlang/Elixir would) expects a different kind of input. We might model these as different functions fn(a) -> GenServerResponse(b, state)
  • The difficulty now becomes the internal plumbing to create from the collection of function-pairs (the public ‘wrapper’ together with the internal ‘state updating+responding function’) the proper code that correctly dispatches whenever messages come in. The difficulty here is that the a and b type variables in each of these function pairs might be different: how do we now pattern-match on the various a's?

Polymorphic variants I think you mean for this context?


I still see major issues with statically typing mailboxes. First, the beam can send/receive any message, so what happens when you will eventually get a message you don’t handle? Second, what if you need to dynamically build an unknown shaped message to some potentially remote endpoint? Whole variety of other issues but these are a couple of the big ones. I’m still for that messages should be black-boxed with a statically typed parser built into the language for them but devolve to a fully dynamic API of beam types?

I think you can basically make an API for a Server that’s based on how you would handle working with agents.
A call looks like a get_and_update where a cast looks like a update only.

Sending an anonymous function from outside the process makes building an agent quite different to building a genserver. However once you have wrapped the details behind a my_buisness_process module using them could be fairly similar.


I would say “you crash”. it’s what happens already in my elixir & erlang programs. I’ve never got use from unhandleable messages in my program, if I see a lot of unexpected messages logs then I try to work out where they can from and fix it so they don’t arrive.

How do you build a message if you don’t know the shape of what you are building?

2 Likes

Possibility of pure message sending

I don’t think this works with the reply structure you have suggested but, it might be possible as follows.

First we define an envelop type, which contains a pair of a Pid and Message, The Envelope type needs to NOT be parameterized by message type, but this is ok because in the erlang receive code implicit matching is done from the messages in the mailbox. An envelope will only be used to send the message, it should not be used to update the contents. The envelope function can also be though of as send later

opaque type Envelope {
  Envelope(Pid(Dynamic), Dynamic)
}

fn envelope(pid: Pid(m), message: m) -> Envelope
// or call send_later

There needs to be some care here because turning a pid of m → pid of dynamic is unsafe, if you send other dynamic values. However with an opaque type it’s safe because the pid of m and message of m are always cast to dynamic types together.

Other things can be put in envelopes. e.g. a From

fn reply_envelope(from: From(m), message: m) -> Envelope {
  let From(ref, pid) = from
  let message = tuple(ref, message)
  envelope(pid, message)
}

Now a handle function can return a list of envelopes to send.

fn handle(message, state) -> tuple(state, List(Envelope))

Stack example

type Message(a) {
    Push(a)
    Pop(From(a))
}

pub fn handle(message, state) {
    case message {
        Push(new) -> {
          tuple([new, ..state], [])
        }
        Pop(from) -> {
            let tuple(top, state) = list.pop(state)
            tuple(state, [process.reply_envelope(from, top)])
        }
    }
}

pub fn init() {
    []
}

gen_server.spawn_link(init, handle)