Tech

A better API for Elm with Elixir

In this blog post, I'm going to outline one of the ideas for improving Elm integration with Elixir. These things could also apply to other backend stacks, but right now we're focusing on Elixir with Phoenix.

Union types are a way of representing a set of possible "things". In Elm, you might be most familiar for using them for pattern matching for actions to figure out what just happened in your update function. For example, you might have


type Action
    = Add
    | Sub

update : Action -> Int
update action =
    case action of
        Add ->
            1 + 1
        Sub ->
            1 - 1

This applies elsewhere. Imagine a function like this:


def do_stuff(action) do
    if action == "Add" do
        1 + 1
    else if action == "Sub" do
        1 - 1
    end
end

where your action is limited to the set ["Add", "Sub"]. Elixir doesn't quite have first-class support for representing this kind of fixed data range, but it does have a way of dealing with it nonetheless. For those coming from languages with more powerful forms of pattern matching, this may seem archaic.

So, let's build one that with function-based pattern matching:


def do_stuff("Add"), do: 1 + 1
def do_stuff("Sub"), do: 1 - 1
def do_stuff(_), do: 0

def example do
    do_stuff("Add")
    do_stuff("Sub")
    do_stuff("DoesntExist")
end

Here, do_stuff will do things based on the string you pass it. If you pass it a string of Add, it'll add 1 to 1. If the value you pass it isn't one of the explicitly listed values, then it will fall down to the default with def do stuff(_). Underscores are wildcard matches. Patterns in Elixir are matched based on the order they are listed in. Always list wildcard matches last. Interestingly, Erlang's design principle is not to try to catch all possible cases, but instead let the ones you don't list fail.

But if we can prevent something failing, why don't we?

Okay, so how do we tie this to Elm? A traditional example might look like pattern matching on the action, then encoding with a name to send to the server. For example,


encodeAction : Action -> Json.Value
encodeAction action =
    case action of
        Add ->
            Json.Encode.object
                [ ( "action", Json.Encode.string "Add") ]
        Sub ->
            Json.Encode.object
                [ ( "action", Json.Encode.string "Sub") ]

decodeResponse : Json.Decode.Decoder Int
decodeResponse =
    Json.Decode.int

sendAction : Action -> Task Error Int
sendAction action =
    Http.post (Json.Decode.int) "localhost:4000/api" (encodeAction action)

and then the server code might look like


def api(conn, params) do
    action = Map.get(params, "action")

    json conn, do_stuff(action)
end

As you can see, this is a pretty verbose solution for only two very simple actions. And we haven't even started to enforce the API yet.

What if I told you that in Elm, union type values always look something like:

var Add = function() {
    return {
        ctor: 'Add'
    };
};

ctor stands for constructor. Add has a ctor of Add. Sub has a ctor of Sub. RunLongThing Int String has a ctor of RunLongThing. This might give you a clue as to what the next API look like! This is an implementation detail, but the structure of Elm objects are unlikely to change much.

asIs : a -> Json.Value
asIs = ..

decodeResponse : Json.Decode.Decoder Int
decodeResponse =
    Json.Decode.int

sendAction : Action -> Task Error Int
sendAction action =
    Http.post (Json.Decode.int) "localhost:4000/api" (asIs action)

with the server code


def api(conn, params) do
    action = Map.get(params, "ctor")

    json conn, do_stuff(action)
end

Great! We've just saved ourselves a whole bunch of time, because now we don't need to write the encoding manaully. Let's improve our application a bit. Now, instead of just doing an Add, we want to be able to add or subtract two arbitrary numbers and run that calculation on the server. Let's have a look at an example of that

type Action
    = Add Int Int
    | Sub Int Int
def do_stuff("Add", n, m) do n + m end
def do_stuff("Sub", n, m) do n - m end

Now, here's a tricky question. How do we give do_stuff the two arguments? Let's test it out with the traditional encoding method:


encodeAction : Action -> Json.Value
encodeAction action =
    case action of
        Add n m ->
            Json.Encode.object
                [ ( "action", Json.Encode.string "Add")
                , ( "n", Json.Encode.int n )
                , ( "m", Json.Encode.int m )
                ]
        Sub n m ->
            Json.Encode.object
                [ ( "action", Json.Encode.string "Sub")
                , ( "n", Json.Encode.int n )
                , ( "m", Json.Encode.int m )
                ]

with the updated server code


def api(conn, params) do
    action = Map.get(params, "ctor")
    n = Map.get(params, "n")
    m = Map.get(params, "m")

    json conn, do_stuff(action, n, m)
end

Suddenly, our server side is less flexible and less useful. What if we wanted to add a constructor AddThree Int Int Int to our Actions? Or what about a constructor like AddStrings String String? Things are gonna get messy pretty fast, especially if we decide to then pass params to each instance and handle the parsing of arguments in there..

However, what if I told you there was a way of getting all the arguments to a constructor value, in order? That is to say, for a given constructor Add 1 2, I would be able to give you the list [1, 2]. Well, I can. You see, Add 1 2 gets turned into a JS function that looks like this:


var Add = function(a, b){
    return {
        ctor: "Add",
        _0: a,
        _1: 2
    };
};

This is obviously an implementation detail! Keep this in mind.

Now in Elixir, I can have a function that looks a little like this:

def gather_args(nil) do [] end
def gather_args(action) do gather_args(action, 0) end
def gather_args(action, index) do
    current_index = "_#{index}"
    value = Map.get(action, current_index)

    if value == nil do
        [ ]
    else
        [ value | gather_args(action, index + 1) ]
    end
end

def api(conn, params) do
    action = Map.get(params, "ctor")
    args = gather_args(action)

    all_args = [ action ] ++ args

    json conn, apply(do_stuff, all_args)
end

apply takes a function name, and a list of arguments, and expands an array out into a function call, for example like this:

fn = add
args = [1, 2, 3]

fn(args[0], args[1], args[2])

For a final comparison, here's the two different approaches side by side:

the new way


  def run_action("Sub", model, n) do model - n end
  def run_action("Add", model, n) do model + n end
  def run_action("AddTwo", model, n, m) do n + m end
  def run_action("RecordAdd", model, record) do
    add = Map.get(record, "add", 0)
    model + add
  end
  def run_action(\, model, \) do model end

  def api(conn, params) do
    action = Map.get(params, "action")
    model = Map.get(params, "model")

    action_name = Elmxir.get_action_name(action)
    args = Elmxir.gather_args(action)

    full_arguments = [ action_name, model ] ++ args

    json conn, %{model: apply(run_action, full_arguments)}
  end

type alias Model = Int

type alias ExampleAdder =
    { add : Int
    }

type ServerCommand
    = ConsoleLog
    | Add Int
    | Sub Int
    | AddTwo Int Int
    | RecordAdd ExampleAdder

decodeModel : Decode.Decoder Model
decodeModel =
    "model" := Decode.int

encodeThings : ServerCommand -> Model -> Json.Encode.Value
encodeThings command model =
    Json.Encode.object
        [ ( "action", asIs command )
        , ( "model", asIs model )
        ]

the old way


def run_action("Sub", model, n) do model - n end
def run_action("Add", model, n) do model + n end
def run_action("AddTwo", model, n, m) do n + m end
def run_action("RecordAdd", model, record) do
  add = Map.get(record, "add", 0)
  model + add
end
def run_action(\, model, \) do model end

def api(conn, params) do
  action = Map.get(params, "action")
  model = Map.get(params, "model")
  action_name = Map.get(action, "name")
  n = Map.get(action, "n")
  m = Map.get(action, "m")
  record = Map.get(action, "record")

  full_arguments = [ action_name, model ] ++ filter(&(&1 == nil), args)

  json conn, %{model: apply(run_action, full_arguments)}
end

type alias Model = Int

type alias ExampleAdder =
    { add : Int
    }

type ServerCommand
    = ConsoleLog
    | Add Int
    | Sub Int
    | AddTwo Int Int
    | RecordAdd ExampleAdder

decodeModel : Decode.Decoder Model
decodeModel =
    "model" := Decode.int


encodeExampleAdder : ExampleAdder -> Json.Encode.Value
encodeExampleAdder record =
    Json.Encode.object
        [ ( "add", Json.Encode.int record.add ) ]

encodeCommand : ServerCommand -> Json.Encode.Value
encodeCommand command =
    let
        encoder =
            case command of
                ConsoleLog ->
                    [ ( "name", Json.Encode.string "ConsoleLog" )
                    ]
                Add n ->
                    [ ( "name", Json.Encode.string "Add" )
                    , ( "n", Json.Encode.int n)
                    ]
                Sub n ->
                    [ ( "name", Json.Encode.string "Sub" )
                    , ( "n", Json.Encode.int n)
                    ]
                AddTwo n m ->
                    [ ( "name", Json.Encode.string "AddTwo" )
                    , ( "n", Json.Encode.int n)
                    , ( "m", Json.Encode.int m)
                    ]
                RecordAdd record ->
                    [ ( "name", Json.Encode.string "Add" )
                    , encodeExampleAdder record
                    ]
    in
        Json.Encode.object encoder

encodeThings : ServerCommand -> Model -> Json.Encode.Value
encodeThings command model =
    Json.Encode.object
        [ ( "action", encodeCommand command )
        , ( "model", Json.Encode.int model )
        ]

Hopefully you can see some of the benefits in this method. The next step is to automate validation and generation of server-side code to match your client side APIs from your Elm code, but that's for another blog post.

I've moved these Elixir helper functions out into a package called elmxir. As Elm changes internally, I will keep this package in sync!

If you want to talk to us about anything in this blog post, both myself and Michael Glass will be at ElixirConfEU.


Noah Hall
@eeue56
Engineer at NoRedInk