8.5

Protecting Boundaries between Elm and JavaScript

What happens if we send a number instead of a string from JavaScript to our Elm app? Let’s find out by replacing Hey Elm! with 10 in the callback function given to app.ports.sendData.subscribe inside beginning-elm/index.html.

<script>
    .
    .
    app.ports.sendData.subscribe(function(data) {
        console.log("Data from Elm: ", data);
        app.ports.receiveData.send(10);
    });
</script>

Reload index.html in a browser and click the Send Data to JavaScript button. Number 10 doesn’t appear on the page. To find out why, open the browser console.

Unlike JavaScript, Elm is very strict about data types. Since we said the receiveData port will receive a value of type Model, which is just String behind the scenes, Elm doesn’t let any other types through that port.

port receiveData : (Model -> msg) -> Sub msg

This is a great news because Elm has our back if the JavaScript code misbehaves by trying to sneak in a type we aren’t expecting. We can actually make this mismatched type detection process even better by displaying a helpful message to the users instead of just crashing our app.

Which Types are Allowed through Ports?

Before we improve our app by not letting it crash whenever a wrong type is received from JavaScript, let’s understand what types of data Elm allows through both incoming and outgoing ports.

Interacting with JavaScript code from an Elm app is very similar to how we interact with an HTTP server. Therefore, to keep things simple Elm prefers to stick with JSON when sending and receiving data from JavaScript as well.

JSON stands for JavaScript Object Notation and is derived from JavaScript. It’s not a coincidence that all of the valid types in JSON listed below — except null — are also available in JavaScript. This makes converting JSON values to JavaScript and vice versa incredibly easy.

Note: In JavaScript, null is a value whose type is object. Unfortunately, this is a bug in the language according to Brendan Eich — the creator of JavaScript. Luckily, JSON sidestepped this bug by creating a separate type for null.

  • string - A string must be written in double quotes and looks very similar to an Elm string.

  • number - A number must be an integer or a float.

  • boolean - A boolean must be either true or false.

  • null - null is used to indicate an absence of a value.

  • Array - An array can contain any other JSON values including arrays themselves.

  • object - An object consists of key value pairs.

Despite its roots in JavaScript, JSON is language independent. In fact, it was originally created to simplify the interchange of data between applications regardless of which language they were written in. Now you can see why the designers of Elm chose JSON for communicating with JavaScript code.

The table below shows the mapping between Elm and JSON values when we send and receive data from JavaScript via ports.

Elm Value Elm Type JSON Value JSON Type JavaScript Type
"Hello JavaScript" String "Hello JavaScript" string string
10 Int 10 number number
3.14 Float 3.14 number number
True Bool true boolean boolean
Nothing Maybe a null null object
{ age = 25 } Record {"age" : 25} object object
( 9, "Pam", False ) Tuple [9, "Pam", False] array Array
[ 1, 2 ] List Int [1, 2] array Array
[ "Mr", "Robot" ] List String ["Mr", "Robot"] array Array

Decoding JSON Values Received from JavaScript

By default, the runtime is in charge of converting data between Elm and JavaScript. For example, when receiving data from JavaScript, the runtime first converts JavaScript values to JSON and then decodes the JSON into corresponding Elm values. That’s why we didn’t have to decode the data coming from JavaScript ourselves. Elm knew how to translate it properly just by looking at the type of our incoming port function.

port receiveData : (Model -> msg) -> Sub msg

A downside of letting the runtime do the decoding behind the scenes is that if the JavaScript code sends an incorrect type, our app simply crashes. A better approach is to do the decoding ourselves. That way if anything goes wrong, we’ll be able to show a proper error message to the user.

Modifying Incoming Data’s Type

Let’s start by changing the type of receiveData incoming port from Model to Value in PortExamples.elm.

port receiveData : (Value -> msg) -> Sub msg

The Value type represents a JSON value. It’s defined in the Json.Encode module, but the Json.Decode module also makes it available through the use of type alias.

type alias Value =
    Value

This way we don’t have to import Json.Encode if all we’re doing is use the Value type. Import Json.Decode in PortExamples.elm.

port module PortExamples exposing (main)

import Json.Decode exposing (Error(..), Value, decodeValue, string)
.
.

We’ve also exposed the Error type, string decoder and decodeValue function. We’ll use them later.

Handling ReceivedDataFromJS Differently

Now that the type of our incoming data has changed from Model to Value, we need to modify the ReceivedDataFromJS message in PortExamples.elm.

type Msg
    = SendDataToJS
    | ReceivedDataFromJS Value

This means we can’t simply return incoming data as model inside the ReceivedDataFromJS data -> branch in update anymore. We need to decode the incoming JSON first. Let’s make that change.

update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        .
        .
        ReceivedDataFromJS value ->
            case decodeValue string value of
                Ok data ->
                    ( { model | dataFromJS = data }, Cmd.none )

                Err error ->
                    ( { model | jsonError = Just error }, Cmd.none )

decodeString vs decodeValue

In the Decoding JSON section, we used decodeString to parse a raw string fetched from an HTTP server into JSON and then used the string decoder to transform that JSON into an Elm string.

> decodeString string "\"Beanie\""
Ok "Beanie" : Result String String

The figure below shows the process decodeString goes through while decoding values from a raw JSON string.

The code we just added to the ReceivedDataFromJS value -> branch in update doesn’t use decodeString. It uses decodeValue instead. That’s because the data coming from JavaScript is already a valid JSON. An HTTP server on the other hand sends a raw string which must be parsed first to make sure that it’s a valid JSON. That’s why we had to use decodeString in the Decoding JSON section. The decodeValue function skips the parsing altogether and focuses on transforming a valid JSON into an Elm value as shown in the figure below.

Here’s how decodeString and decodeValue’s type signatures differ:

Modifying the Model

We’re using a case expression to handle both success and failure scenarios inside the ReceivedDataFromJS value -> branch of update.

update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        .
        .
        ReceivedDataFromJS value ->
            case decodeValue string value of
                Ok data ->
                    ( { model | dataFromJS = data }, Cmd.none )

                Err error ->
                    ( { model | errorMessage = Just error }, Cmd.none )

If decoding succeeds, the decoded value gets assigned to the dataFromJS field in our model. If it fails, we need to store the error in jsonError field. Let’s add those fields to Model in PortExamples.elm.

type alias Model =
    { dataFromJS : String
    , jsonError : Maybe Error
    }

We also need to modify init to comply with the new model structure.

init : () -> ( Model, Cmd Msg )
init _ =
    ( initialModel, Cmd.none )


initialModel : Model
initialModel =
    { dataFromJS = ""
    , jsonError = Nothing
    }

Displaying Error Message

The only thing left is to display the error message produced by decodeValue if decoding fails. Replace the view function in PortExamples.elm with the following code.

view : Model -> Html Msg
view model =
    div []
        [ button [ onClick SendDataToJS ]
            [ text "Send Data to JavaScript" ]
        , viewDataFromJSOrError model
        ]


viewDataFromJSOrError : Model -> Html Msg
viewDataFromJSOrError model =
    case model.jsonError of
        Just error ->
            viewError error

        Nothing ->
            viewDataFromJS model.dataFromJS


viewError : Error -> Html Msg
viewError jsonError =
    let
        errorHeading =
            "Couldn't receive data from JavaScript"

        errorMessage =
            case jsonError of
                Failure message _ ->
                    message

                _ ->
                    "Error: Invalid JSON"
    in
    div []
        [ h3 [] [ text errorHeading ]
        , text ("Error: " ++ errorMessage)
        ]


viewDataFromJS : String -> Html msg
viewDataFromJS data =
    div []
        [ br [] []
        , strong [] [ text "Data received from JavaScript: " ]
        , text data
        ]

The jsonError field in our model is of type Json.Decode.Error. Here’s how it’s defined:

type Error
    = Field String Error
    | Index Int Error
    | OneOf (List Error)
    | Failure String Value

Note: If you don’t remember how the Error type works, you may want to review the Decoding JSON - Part 1 section.

The error message we’re interested in is inside Failure. That’s why we ignored all other data constructors.

Testing

We’re now ready to test. Recompile PortExamples.elm by running the following command from the beginning-elm directory in terminal.

$ elm make src/PortExamples.elm --output elm.js

Everything should compile fine. Reload the beginning-elm/index.html file in a browser and open browser console. Click the Send Data to JavaScript button. Elm now shows a friendly error message on the page itself instead of crashing the app and pointing out what went wrong in the console.

Let’s fix that error by sending Hey Elm! instead of 10 from JavaScript in index.html.

<script>
    .
    .
    app.ports.sendData.subscribe(function(data) {
        console.log("Data from Elm: ", data);
        app.ports.receiveData.send("Hey Elm!");
    });
</script>

Reload index.html and click the Send Data to JavaScript button. You should see Hey Elm!.

You may be wondering why we had to go through such an elaborate process to receive a simple string from JavaScript. Actually the process for receiving more complex data from JavaScript is also very similar. The only difference is that we’ll need to write decoders that are much more sophisticated than string.

Sending Complex Data to JavaScript

The process for sending complex Elm data to JavaScript is quite similar to the one we used for sending a string in the Sending Data to Javascript section. Why don’t we create some complex data in our Elm app and try to send it to JavaScript to understand this process better? Here’s how the data we want to send looks after it gets translated to JSON:

{
  "posts": [
    {
      "id": 1,
      "title": "json-server",
      "author": {
        "name": "typicode",
        "url": "https://github.com/typicode"
      }
    },
    {
      "id": 2,
      "title": "http-server",
      "author": {
        "name": "indexzero",
        "url": "https://github.com/indexzero"
      }
    }
  ],
  "comments": [
    {
      "id": 1,
      "body": "some comment",
      "postId": 1
    }
  ],
  "profile": {
    "name": "typicode"
  }
}

Let’s use records to represent various JSON objects shown above. Add the following code right below the Model type in PortExamples.elm.

type alias ComplexData =
    { posts : List Post
    , comments : List Comment
    , profile : Profile
    }


type alias Post =
    { id : Int
    , title : String
    , author : Author
    }


type alias Author =
    { name : String
    , url : String
    }


type alias Comment =
    { id : Int
    , body : String
    , postId : Int
    }


type alias Profile =
    { name : String }

Next add a new field called dataToJS to Model in PortExamples.elm.

type alias Model =
    { dataFromJS : String
    , dataToJS : ComplexData
    , jsonError : Maybe Error
    }

We need to update init to create an initial value for dataToJS. Replace the current implementation of init with the following in PortExamples.elm.

init : () -> ( Model, Cmd Msg )
init _ =
    ( initialModel, Cmd.none )


initialModel : Model
initialModel =
    { dataFromJS = ""
    , dataToJS = complexData
    , jsonError = Nothing
    }


complexData : ComplexData
complexData =
    let
        post1 =
            Author "typicode" "https://github.com/typicode"
                |> Post 1 "json-server"

        post2 =
            Author "indexzero" "https://github.com/indexzero"
                |> Post 2 "http-server"
    in
    { posts = [ post1, post2 ]
    , comments = [ Comment 1 "some comment" 1 ]
    , profile = { name = "typicode" }
    }

Since we want to send more complex data to JavaScript, we need to change the type of sendData outgoing port from String to ComplexData in PortExamples.elm.

port sendData : ComplexData -> Cmd msg

We also need to modify the SendDataToJS -> branch in update to use the dataToJS field in our model instead of "Hello JavaScript!".

update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        SendDataToJS ->
            ( model, sendData model.dataToJS )

        ReceivedDataFromJS value ->
            ...

We’re now ready to recompile PortExamples.elm. Run the following command from the beginning-elm directory in terminal.

$ elm make src/PortExamples.elm --output elm.js

Let’s make the console output easier to read by converting the JavaScript value sent by Elm to a raw JSON string. Replace data with JSON.stringify(data) in index.html.

<script>
    .
    .
    app.ports.sendData.subscribe(function(data) {
        console.log("Data from Elm: ", JSON.stringify(data));
        app.ports.receiveData.send("Hey Elm!");
    });
</script>

Reload index.html and open browser console. Click Send Data to JavaScript and you should see a JSON representation of the complex data sent from Elm in the console.

The runtime translated individual Elm types contained in complexData to corresponding JSON types. All of that translation happened behind the scenes. We didn’t have to do anything other than hand Elm value over to the outgoing port.

Summary

One of the biggest advantages of using Elm is it guarantees there won’t be any type errors when an app is running. To fulfill that guarantee, Elm must reject all incorrectly typed values from entering the app. The runtime will throw an error as soon as it detects an incorrect type trying to sneak through an incoming port.

It’s not a good practice to let Elm crash our app if automatic decoding fails. We can prevent that by decoding the incoming data ourselves. This also allows us to display a friendly message to the user if decoding does fail. Here is the entire code from PortExamples.elm for your reference:

port module PortExamples exposing (main)

import Browser
import Html exposing (..)
import Html.Events exposing (onClick)
import Json.Decode exposing (Error(..), Value, decodeValue, string)


type alias ComplexData =
    { posts : List Post
    , comments : List Comment
    , profile : Profile
    }


type alias Post =
    { id : Int
    , title : String
    , author : Author
    }


type alias Author =
    { name : String
    , url : String
    }


type alias Comment =
    { id : Int
    , body : String
    , postId : Int
    }


type alias Profile =
    { name : String }


type alias Model =
    { dataFromJS : String
    , dataToJS : ComplexData
    , jsonError : Maybe Error
    }


view : Model -> Html Msg
view model =
    div []
        [ button [ onClick SendDataToJS ]
            [ text "Send Data to JavaScript" ]
        , viewDataFromJSOrError model
        ]


viewDataFromJSOrError : Model -> Html Msg
viewDataFromJSOrError model =
    case model.jsonError of
        Just error ->
            viewError error

        Nothing ->
            viewDataFromJS model.dataFromJS


viewError : Error -> Html Msg
viewError jsonError =
    let
        errorHeading =
            "Couldn't receive data from JavaScript"

        errorMessage =
            case jsonError of
                Failure message _ ->
                    message

                _ ->
                    "Error: Invalid JSON"
    in
    div []
        [ h3 [] [ text errorHeading ]
        , text ("Error: " ++ errorMessage)
        ]


viewDataFromJS : String -> Html msg
viewDataFromJS data =
    div []
        [ br [] []
        , strong [] [ text "Data received from JavaScript: " ]
        , text data
        ]


type Msg
    = SendDataToJS
    | ReceivedDataFromJS Value


update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        SendDataToJS ->
            ( model, sendData model.dataToJS )

        ReceivedDataFromJS value ->
            case decodeValue string value of
                Ok data ->
                    ( { model | dataFromJS = data }, Cmd.none )

                Err error ->
                    ( { model | jsonError = Just error }, Cmd.none )


subscriptions : Model -> Sub Msg
subscriptions _ =
    receiveData ReceivedDataFromJS


port sendData : ComplexData -> Cmd msg


port receiveData : (Value -> msg) -> Sub msg


init : () -> ( Model, Cmd Msg )
init _ =
    ( initialModel, Cmd.none )


initialModel : Model
initialModel =
    { dataFromJS = ""
    , dataToJS = complexData
    , jsonError = Nothing
    }


complexData : ComplexData
complexData =
    let
        post1 =
            Author "typicode" "https://github.com/typicode"
                |> Post 1 "json-server"

        post2 =
            Author "indexzero" "https://github.com/indexzero"
                |> Post 2 "http-server"
    in
    { posts = [ post1, post2 ]
    , comments = [ Comment 1 "some comment" 1 ]
    , profile = { name = "typicode" }
    }


main : Program () Model Msg
main =
    Browser.element
        { init = init
        , view = view
        , update = update
        , subscriptions = subscriptions
        }
Back to top
Close