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 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.