Bridging Elm and JavaScript with Ports

Josh Clayton

Elm is a programming language for building client-side web applications that compiles to JavaScript. Because it’s strongly-typed and pure, Elm provides a handy mechanism for interacting with the “unsafe world” that is JavaScript to help maintain boundaries between the two.

That mechanism is ports.

Why Ports?

Because Elm relies on an explicit understanding of the data it describes via the type system, it needs to make guarantees when interacting with JavaScript so developers can trust the compiler to verify accuracy in the program. Elm touts “no runtime exceptions”, and while I’ve personally found ways to break that (hint: don’t attempt to convert user input into a regular expression), I trust the compiler to help spot issues.

Because of these guarantees, ports are necessary to guard what flows into the Elm application from JavaScript, and allows developers to format data from Elm flowing outward to JavaScript. Ports act as a bridge between two different worlds, and introduce confidence that communication between the two languages is occurring correctly.

Limited Interface

Ports allow for basic primitives to flow to and from JavaScript and Elm, including JavaScript objects, arrays, boolean values, numbers, and strings. Functions cannot be passed directly, even though they’re first-class objects in JavaScript. Similarly, functions cannot be passed from Elm to JavaScript to be called afterward.

Because of this limited interface, it’s arguably easier to ensure both Elm and JavaScript are able to communicate; there’s a much smaller language with which they can speak to each other.

Forced Constraints and Forced Failure

While I’ve written at length about Elm decoders, their purpose is to parse JSON structures into Elm-specific data structures. Elm provides a Result a b primitive type that describes both success and failure; with this, the Elm code will then explicitly handle when parsing JSON fails, and provide information to the developer (and perhaps customer) that something went wrong.

Forced error handling means explicit messaging to customers; edge-cases around unexpected things happening crop up much less frequently.

Managing Side-Effects

Port interaction is handled entirely through Elm as asynchronous messages. This means all inbound port interaction is handled either with Elm subscriptions (JavaScript sending data over a port to Elm) and outbound port interaction with Elm commands (Elm sending data to JavaScript over a port).

Because of this, communication via ports is often isolated to a single file, ensuring all code with side-effects is isolated from the rest of the codebase.

-- outbound port
port initialized : List GoogleMapMarker.Model -> Cmd a


-- outbound port
port selectLatLon : GoogleMapMarker.Model -> Cmd a


-- inbound port
port clickedMapMarker : (Int -> msg) -> Sub msg

Example Application

For this article, I built an Elm application that plots thoughtbot offices on a Google Map. The data is seeded via JavaScript objects and passed into the Elm application with a flag, and once the data is decoded, it’s plotted on the map.

Elm Ports Example Application Screenshot

Elm to JavaScript

Let’s start by sending information from Elm to JavaScript. This occurs at the top-level initial and update functions within the Elm application as a command, and we’ll need a way to encode an Elm structure into JSON.

Sending Messages to Ports

The first port we’ll define will be used to initialize the Google Map itself; with this, we’ll provide a list of geographical coordinates (latitude and longitude) that we can iterate over to plot on the map.

Laying the foundation of the data model, let’s look at what a Google Map marker looks like:

module GoogleMapMarker
    exposing
        ( Model
        , fromOffice
        )

import Json.Encode as Encode
import Office.Model as Office
import Address.Model as Address


type alias Model =
    Encode.Value


fromOffice : Office.Model -> Model
fromOffice ({ id } as office) =
    Encode.object
        [ ( "id", encodeOfficeId id )
        , ( "latLng", encodeLatLon office.address.geo )
        ]

To make this list of offices available to JavaScript, we’ll begin by defining a port:

port initialized : List GoogleMapMarker.Model -> Cmd a

This defines a function that takes a list of GoogleMapMarker.Model and returns a polymorphic Cmd a; with this in place, we can call initialized from initial (which we’ve told Elm to use when starting the application) and provide the list of coordinates:

initial : Flags.Model -> ( Model, Cmd Msg )
initial flags =
    let
        initialModel =
            initialModelFromFlags flags

        initialLatLngs =
            List.map GoogleMapMarker.fromOffice initialModel.offices
    in
        ( initialModel, initialized initialLatLngs )

When we embed the Elm application, we’ll subscribe to the initialized port, initialize the map, and register the coordinates. Let’s take a look at the JavaScript necessary to do so:

const flags = {
  offices: [
    {
      id: 1,
      name: "Boston",
      // ...
    }
  ]
}

document.addEventListener("DOMContentLoaded", () => {
  const app = Elm.Main.embed(document.getElementById("main"), flags);

  app.ports.initialized.subscribe(latLngs => {
    window.requestAnimationFrame(() => {
      const map = new Map(
        window.google,
        document.getElementById("map")
      );
      map.registerLatLngs(latLngs);
    });
  });
});

As you can see, we’ve extracted interacting with the Google Map into a new JavaScript class, Map. Its constructor accepts a reference to google, as well as the element we’ll be embedding the map within. The registerLatLngs function takes the list of coordinates, plots the markers, and updates map boundaries so all points are displayed at the appropriate zoom level.

Of note is the use of window.requestAnimationFrame(); because we’re outside of Elm’s rendering in the JavaScript side of this port, we wrap modification of the Elm-controlled DOM in this callback to ensure smooth rendering.

Encoding Data Structures with Json.Encode

Looking at the type signature for initialized, we can see it expects a list of GoogleMapMarker.Model. Ports in Elm can provide data to JavaScript in two ways: send along only Elm primitives like String or Bool, or use the Json.Encode.Value type to represent a JSON structure, and have the developer define how a structure is encoded.

Let’s look at the GoogleMapMarker module to dig into encoding.

module GoogleMapMarker
    exposing
        ( Model
        , fromOffice
        )

import Json.Encode as Encode
import Office.Model as Office
import Address.Model as Address


type alias Model =
    Encode.Value


fromOffice : Office.Model -> Model
fromOffice ({ id } as office) =
    Encode.object
        [ ( "id", encodeOfficeId id )
        , ( "latLng", encodeLatLon office.address.geo )
        ]


encodeOfficeId : Office.Id -> Encode.Value
encodeOfficeId (Office.Id id) =
    Encode.int id


encodeLatLon : Address.LatLon -> Encode.Value
encodeLatLon latLon =
    Encode.object
        [ ( "lat", Encode.float latLon.latitude )
        , ( "lng", Encode.float latLon.longitude )
        ]

Here, we alias Model to be Json.Encode.Value, and provide the function fromOffice to handle encoding. The JSON generated should be straightforward:

{
  "id": 1,
  "latLng": {
    "lat": 42.356157,
    "lng": -71.061634
  }
}

JavaScript Data Structures

With the JSON structure in place, let’s look quickly at the registerLatLngs function from Map and see how the data is used in JavaScript.

export default class Map {
  // ...

  registerLatLngs(latLngs) {
    const bounds = new this.google.maps.LatLngBounds();

    latLngs.forEach(o => {
      const gLatLng = o.latLng;
      bounds.extend(gLatLng);

      const marker = new this.google.maps.Marker({
        position: gLatLng,
        map: this.map
      });
    });

    this.map.fitBounds(bounds);
  }
}

Because registerLatLngs receives an array of JavaScript objects, we can iterate over the list and build markers for Google Maps, as well as extend the map bounds, in a single forEach. Google Maps expects a specific structure for the coordinate, captured in latLng, so we pass it through directly.

Messages to Ports via update

With our markers rendered, the next thing we’ll cover is selecting an office in Elm, which should pan the map to the appropriate marker.

First, let’s create a new port for selecting a particular office:

port selectLatLon : GoogleMapMarker.Model -> Cmd a

In the update function, we’ll now handle the new message:

update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        SelectOffice office ->
            { model | selectedOffice = Just office }
                |> navigateMapsToOffice

        -- other messages


navigateMapsToOffice : Model -> ( Model, Cmd Msg )
navigateMapsToOffice model =
    let
        cmd =
            case model.selectedOffice of
                Nothing ->
                    Cmd.none

                Just office ->
                    selectLatLon <| GoogleMapMarker.fromOffice office
    in
        ( model, cmd )

When a selected office exists, we use the selectLatLon port to notify the JavaScript.

The underlying JavaScript needs to be updated a bit so the new port can also reference the map:

document.addEventListener("DOMContentLoaded", () => {
  const app = Elm.Main.embed(document.getElementById("main"), flags);

  let map; // make map available

  app.ports.initialized.subscribe(latLngs => {
    window.requestAnimationFrame(() => {
      map = new Map(
        window.google,
        document.getElementById("map")
      ); // assign to map
      map.registerLatLngs(latLngs);
    });
  });

  app.ports.selectLatLon.subscribe(latLng => {
    map.selectLatLng(latLng); // call a function on our now-available map
  });
});

Within our Map, we add the function selectLatLng to handle map interaction:

export default class Map {
  // ...

  selectLatLng(o) {
    this.map.panTo(o.latLng);
    this.map.setZoom(12);
  }
}

When selecting an office, the map now pans to the appropriate position!

JavaScript to Elm

With outgoing ports configured, the final step we need to take is capturing clicking on a marker to select the office Elm-side.

Subscriptions

While outgoing ports are handled as side-effects at the top-level update function, accepting data from incoming ports is handled via subscriptions.

Let’s look at the incoming port and subscriptions functions to see how these fit together:

subscriptions : Model -> Sub Msg
subscriptions _ =
    clickedMapMarker (\id -> SelectOfficeById <| Office.Id id)


port clickedMapMarker : (Int -> a) -> Sub a

In this example, our subscriptions operate regardless of model state, and we ingest an Int, transform it to an Office.Id, and wrap it in the SelectOfficeById message. With this, we’ll need to wire up clicking on a marker to send the id property, and handle this message in update.

update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        SelectOfficeById id ->
            selectOfficeById id model
                |> navigateMapsToOffice

        -- other messages

We’ll leverage navigateMapsToOffice again, which will ensure the map is panned correctly.

We’ll also need to configure the callback when clicking on a marker, so let’s update the JavaScript:

document.addEventListener("DOMContentLoaded", () => {
  const app = Elm.Main.embed(document.getElementById("main"), flags);

  let map;

  app.ports.initialized.subscribe(latLngs => {
    window.requestAnimationFrame(() => {
      map = new Map(
        window.google,
        document.getElementById("map"),
        app.ports.clickedMapMarker.send // pass the inbound port function
      );
      map.registerLatLngs(latLngs);
    });
  });

  app.ports.selectLatLon.subscribe(latLng => {
    map.selectLatLng(latLng);
  });
});

Finally, when we register the initial set of coordinates, we configure a listener on the “click” event on the marker:

export default class Map {

  registerLatLngs(latLngs) {
    const bounds = new this.google.maps.LatLngBounds();

    latLngs.forEach(o => {
      const gLatLng = o.latLng;
      bounds.extend(gLatLng);

      const marker = new this.google.maps.Marker({
        position: gLatLng,
        map: this.map
      });

      marker.addListener("click", () => {
        this.clickedCallback(o.id); // trigger the callback with the office ID
      });
    });

    this.map.fitBounds(bounds);
  }
}

Similar to sending data out of the Elm application, it’s able to handle decoding primitive data structures with ease. In this case, we take the identifier (an Int) and do some simple wrapping to an Office.Id in the subscriptions function above.

If you need to decode larger data structures, the process is largely the same, but you’ll need to build decoders for your Elm types, which you can read more about in another post.

What’s Next for Ports

Ports in Elm are a robust mechanism of sending data between the worlds of Elm and JavaScript. It supports primitive data structures out of the box, resulting in quick prototyping and validation, while allowing for the raw power of Elm’s JSON encoders and decoders when necessary.

Beyond encoding and decoding data, because data passed to Elm still flows through the top-level update function, behavior triggered from JavaScript is easy to reason about because it abides by the same rules as all other actions within the application. Murphy Randle talked about a different approach to ports at ElmConf 2017 that, while I haven’t tried, seems very appealing. I encourage you to watch his talk.