Midas

Package Version Hex Docs

Midas tackles the function colouring problem by separating defining and running effectful code. It provides a description of effects, as well functions for composing effectful computation.

This separation has several benefits:

gleam add midas@2

Why Midas

Already sold jump to Usage

The goal

Let’s imagine we have some code that we want to run on JavaScript and the BEAM. Also suppose that this code needs to make http requests.

Ideally we write something like the following.

fn my_func(send){
  let request_a = request.new() |> request.set_path("/a")
  let result_a = send(request_a)
  let assert Ok(a) = result_a

  let request_b = request.new() |> request.set_path("/a")
  let result_b = send(request_b)
  let assert Ok(b) = result_b

  todo as "something with a and b"
}

The simple approach here is to pass the client at runtime so different HTTP clients can be used on each runtime.

Gleam has great HTTP clients, there is gleam_httpc for the BEAM and gleam_fetch for JavaScript.

The problem

The problem is function colouring. The two clients have different type signatures for their send function. httpc returns a Result(Response, _) and fetch returns a Promise(Result(Response, _)).

This difference reflects underlying differences in the runtimes and I think is a good thing to capture this in the type system. However this difference blocks us from reusing my_func as it is.

The solution

Instead of passing in the client our code return data type with information on what to do next. In this example our code is either finished or needs to make an HTTP request. We call this returned type Effect and it has two variants Fetch and Done.

pub type Effect(t) {
  Fetch(Request, fn(Result(Response, String)) -> Effect(t))
  Done(t)
}

To write our business logic we make use of use to cleanly compose our effects.


pub fn my_func() {
  let request_a = request.new() |> request.set_path("/a")
  use result_a <- Fetch(request_a)
  let assert Ok(a) = result_a

  let request_b = request.new() |> request.set_path("/a")
  use result_b <- Fetch(request_b)
  let assert Ok(b) = result_b
  Done(todo as "something with a and b")
}

The my_func above only describes the computation but does not run it. To run the function requires a run function, there are libraries for running tasks in different environments.

Simple run functions can be implemented as follows:

for BEAM

pub fn run(effect) {
  case effect {
    Fetch(request, resume) -> run(resume(httpc.send(request)))
    Done(value) -> value
  }
}

for JS

pub fn run(effect) {
  case effect {
    Fetch(request, resume) -> {
      use result <- promise.await(fetch.send(request))
      run(resume(result))
    }
    Done(value) -> promise.resolve(value)
  }
}

Note: The full midas effect type defines many side effects so your runner will either have to implement them, panic or return a default value for the task.

That’s as far as we take this toy implementation, the rest of the documentation will cover using the actual library.

Usage

Midas separates defining tasks from running tasks. So first we shall define a task that makes a web request and logs when it has completed.

import midas/task as t

pub fn task() {
  let request = // ...
  use response <- t.do(t.fetch(request))
  use Nil <- t.do(t.log("Fetched"))
  t.done(Nil)
}

To run this in the browser use midas_browser:

import midas/browser

pub fn main(){
  use result <- promise.await(browser.run(task()))
  case result {
    Ok(_) -> // ...
    Error(_) -> // ...
  }
}

Testing

Because we have isolated all side effects testing is easy. Instead of using any runner the simplest approach is to assert on each effect in turn.

First assert that the effect is as expected, for example that the effect is Fetch and that the request has the right path. Then resume the program and assert on the next effect, or done if no more effects.

import midas/effect as e

pub fn task_success_test() {
  let assert e.Fetch(request:, resume:) = task()
  assert request.path == "/a/b/c"
  
  let response = response.new(200)
  let assert e.Log(message:, resume:) = resume(Ok(response))
  assert messages == "Fetched"
  
  let assert e.Done(Ok(value)) = resume(Ok(Nil))
}

pub fn network_error_test() {
  let assert e.Fetch(request:, resume:) = task()
  assert request.path == "/a/b/c"

  let reason = e.NetworkError("something bad")
  let assert e.Done(Error(reason)) = resume(Error(reason))
  assert string.contains(snag.pretty_print(reason), "something bad")
}

Working with errors

The midas/task module assumes that the return value of a task is a result. All the helper functions assume that an error from an effect, such as a network error from a fetch, should be returned as an error from the task. This is easier to work with and all effects are snags. However this prevents you from recovering or implementing retries.

For more explicit error handling you can use the midas/effect module directly.

A note of effects

The midas/task module defines an Effect type which represents all the effects that can be defined for any task. Gleam doesn’t have an extensible type so the Effect type is essentially a single namespace defining all possible effects.

This is not really a problem because any effect might return an error. So it is always possible to return an error that indicates that the effect is not implemented by a given runner.

Search Document