Using Elm and TypeScript Together

This post is about using TypeScript and Elm together in a project.

I’ll be using the Microsoft Word Add-in from Glvrd Add-in for Microsoft Word as an example. The code is available in the glvrd-addin-2 GitHub repository.

The Elm framework provides ports that allow to communicate with a JavaScript code. You can use them when you need a functionality that is not available in Elm but is presented in JavaScript. This way you can save your time and use JS libraries instead of writing your own in pure Elm.

I’ll concentrate mostly on the TypeScript’s side of the project. The Elm part would not differ from the communication with a vanilla JavaScript.

Check the introduction to ports from the official Elm guide if you are new to ports. Sending Data to JavaScript and Receiving Data from JavaScript articles are a great source of information about Elm ports as well.

The TypeScript part

The TypeScript part of the solution serves the following goals:

  1. Bootstrapping the Elm application.
  2. Working with Elm ports. The TypeScript part acts as a proxy between the Elm application and external APIs In my case—Microsoft Word and Glvrd, an external proofreading service.
  3. Including Elm files into the bundle. Referencing the Elm application main file, so it would be included in the single production bundle.

The logic related to Elm is defined in the ElmApp.ts file.

1. Bootstrapping the Elm application

The ElmApp.ts file contains the startApplication function that starts the Elm application:

export const startApplication: (parameters : IAppParameters) => GlvrdApp = (parameters) => {
    return Elm.Main.fullscreen(parameters);
};

The function is called once the Office Add-in is initialized.

2. Working with Elm ports

For each port and the Elm application itself there is a type declaration which allows controlling the data that goes into or comes out of the Elm application:

export interface IAppParameters {
    language: string;
}

export interface IJstoElmPort<T> {
    send: (params: T) => void;
}

export interface IElmToJsPort<T> {
    subscribe: (callback: T) => void;
}

export type GlvrdApp = {
    ports: {
        suggestions: IJstoElmPort<IProofreadResult>;
        externalError: IJstoElmPort<string>;
        check: IElmToJsPort<(text: string) => void>;
        textChanged: IJstoElmPort<string>;
    };
};

The Index.ts file contains the actual calls to ports using send and subscribe functions of ports.

3. Including Elm files into the bundle

The ElmApp.ts references the main Elm application file Main.elm:

import * as Elm from "./app/Main.elm";

This allows the webpack to add the Elm code to the final bundle during the build. It is done by the elm-webpack-loader loader. The webpack.common.js contains the configuration for the loader:

module.exports = {
    ...

    resolve: {
        extensions: [".ts", ".tsx", ".js", ".jsx", ".elm"]
    },

    ...

    module: {
        rules: [
            {
                test: /\.elm$/,
                exclude: [/elm-stuff/, /node_modules/],
                loader: "elm-webpack-loader",
            }
            ...
        ]
    },
    ...
};

The Elm part

I’ve got one outgoing port check (asks JavaScript to perform a proofread), and three incoming ports: suggestions (receives results of a proofread), textChanged (notifies that the selected text has changed and provides the new text), and externalError (notifies about errors that happened on the JavaScript side).

All the ports logic is defined in the Main.elm file.

Here’s all the code related to ports:

port module Main exposing (..)

...

-- PORTS

port check : String -> Cmd msg

port suggestions : (ProofreadResult -> msg) -> Sub msg

port textChanged : (String -> msg) -> Sub msg

port externalError : (String -> msg) -> Sub msg

...

-- SUBSCRIPTIONS

subscriptions : Model -> Sub Msg
subscriptions model =
    Sub.batch
        [ suggestions Suggest
        , textChanged TextChanged
        , externalError Error
        ]

...

-- UPDATE


type Msg
    = Suggest ProofreadResult
    | TextChanged String
    | Error String
    | SetActiveComment String
    | ResetActiveComment


update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        TextChanged newText ->
            case newText of
                "" ->
                    ( { model | selectedText = newText, proofreadResult = RemoteData.NotAsked }, Cmd.none )

                _ ->
                    ( { model | selectedText = newText, proofreadResult = RemoteData.Loading }, check newText )

        Suggest suggestion ->
            ( { model | proofreadResult = RemoteData.Success suggestion }, Cmd.none )

        Error error ->
            ( { model | proofreadResult = RemoteData.Failure error }, Cmd.none )

...

Scaling ports in Elm

I’ve defined ports in the main logic file, and I’ve got a separate port for each action. This is a simple approach, but it doesn’t scale well.

If you’ve got a lot of ports, you might want to move them to a separate file for a better control. Also, you may consider using only two ports—one for all incoming messages and one for all outgoing.

Check The Importance of Ports talk by Murphy Randle for details.

If your ports operate with complex data, you might want to decode the data coming from JavaScript by yourselves. By default, Elm decodes data automatically, and will fail silently if a conversion cannot be performed between JavaScript and Elm types. A manual decoding allows controlling this process.

Protecting Boundaries between Elm and JavaScript describes this approach in details.

All the SharePoint Cats
Easy Way to Debug Passport Authentication in Express