Elm Outside of the Browser

July 31st, 2026

This post was originally written in 2021. So to celebrate the release of Elm 0.19.2 (after a 6.7 year hiatus!), I decided to revisit it, and include an an example project on GitHub.

The Elm language is a nice functional language for frontend development. It compiles to JavaScript and the syntax is what I like to call Haskell-lite. In fact the compiler is written in Haskell. No type classes or any of the other more sophisticated features, but just enough to have a proper FP experience. I recommend it to people wanting to learn functional programming for the first time.

But can we only use it for frontend development? Can we write a CLI tool in Elm? Turns out you can! With a simple JavaScript wrapper, you can use Node to run Elm programs outside of a browser.

Setup

The Elm Side

Start by installing elm and running the elm init command in the directory you wish to work from. This should create an elm.json file and an empty src directory.

.
├── elm.json
└── src

We can now remove some of the default dependencies, since we won't need any browser support. Elm unfortunately doesn't have a built-in nice way to remove dependencies, since they generally assume you need the defaults (browser, html, etc). So just directly edit the elm.json file to look like the snippet below. You can check that things are working by running elm install elm/core, and you should see It is already installed!.

{
    "type": "application",
    "source-directories": [
        "src"
    ],
    "elm-version": "0.19.2",
    "dependencies": {
        "direct": {
            "elm/core": "1.0.5"
        },
        "indirect": {
            "elm/json": "1.1.4"
        }
    },
    "test-dependencies": {
        "direct": {},
        "indirect": {}
    }
}

For our program to take input and produce outputs without a browser, we'll need a way to communicate with "the outside". In Elm, this is achieved through a feature called ports. We'll need to declare our module as a port module, and declare some port types. For now, our API will be to take in a string and output a string.

Create a new file at src/Main.elm with the following.

port module Main exposing (..)


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


port output : String -> Cmd msg

Now that we have the ports set up, we can wire up a minimal program. In a standard Elm project, we use the Browser module program. But for this project, we will use the Platform module to create a headless program. This is the key ingredient. If you've written standard Elm before, this should look familiar. But notice, we have no view!

port module Main exposing (..)

import Platform exposing (Program, worker)



-- Main


main : Program Flags Model Msg
main =
    worker
        { init = init
        , update = update
        , subscriptions = subscriptions
        }



-- Ports


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


port output : String -> Cmd msg



-- Model


type alias Flags =
    ()


type alias Model =
    ()


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



-- Update


type Msg
    = Input String


update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        Input s ->
            -- immediately output the input string
            ( model, output s )



-- Subscriptions


subscriptions : Model -> Sub Msg
subscriptions _ =
    input Input

We'll compile our project to a build directory.

$ elm make src/Main.elm --output=build/index.js --optimize
Success!

    Main ───> build/index.js

The JavaScript Side

The JavaScript side of things is pretty simple. Create a main.js file with the following.

const Elm = require("./build").Elm;

// Initialize Elm program
const main = Elm.Main.init();

// Handle receiving output
main.ports.output.subscribe(console.log);

const input = process.argv[2] || "default";

// Send message to Elm
main.ports.input.send(input);

And that's it! We have basically just built a complicated echo with Elm!

$ node main.js
default
$ node main.js hello
hello

Where to go from here

Now that this foundation has been laid, there's all sorts of directions we can go. As an example, let's create a CLI tool that lets us manipulate elm.json files. See the full code on GitHub.

Our tool will accept a sequence of operations to manipulate the file, showing the final result at the end. We'll support three commands, remove, demote, and promote. These commands let you remove, demote (move from direct to indirect), or promote (indirect to direct) packages.

Using our tool, we'll be able to go from a default elm.json file, to one that just uses elm/core and elm/json, as this project does.

$ node main.js \
    remove elm/browser elm/html elm/time elm/url elm/virtual-dom \
    promote elm/json \
    --strict

Instead of a generic input port, we'll add one input port for each command. We'll also add a finish port, indicating that we're done sending commands, and we want to see the final output. We'll also add an error output port so the JS side can distinguish successful and unsuccessful results.

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


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


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


port finish : (() -> msg) -> Sub msg


port output : String -> Cmd msg


port error : String -> Cmd msg

We'll update our subscriptions and message type accordingly.

type Msg
    = Remove String
    | Demote String
    | Promote String
    | Finish

subscriptions : Model -> Sub Msg
subscriptions _ =
    Sub.batch
        [ remove Remove
        , demote Demote
        , promote Promote
        , finish (always Finish)
        ]

We can pass data in to initialize our model from the JS side through flags. We'll update our model to hold a strict flag to determine if passing an unknown package is an error or simply ignored. We'll also need a flag for passing in the initial contents of the elm.json file.

The actual decoding of the JSON file is a whole other topic, but is one of the primary reasons you might want to go through the trouple of writing an Elm CLI tool. Instead of a generic JSON.parse, we ensure the correctness of our data by parsing into the expected shape.

type alias Flags =
    { elmJson : String, strict : Bool }


type alias Model =
    { elmJson : Result String Application, strict : Bool }

The model will hold our custom type (Application). Since the decoding may fail (that it, the input JSON may not be a valid elm.json file at all), we have to hold a Result type. We initialize our model from the flags in the init function.

init : Flags -> ( Model, Cmd Msg )
init flags =
    case D.decodeString ElmJson.Decode.decoder flags.elmJson of
        Ok app ->
            ( { elmJson = Ok app, strict = flags.strict }, Cmd.none )
        -- ...

And finally, we need to update update to handle our three command types.

update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case model.elmJson of
        Ok app ->
            case msg of
                Remove pkg ->
                    updateApplication Modify.removePackage pkg app model

                Demote pkg ->
                    updateApplication Modify.demotePackage pkg app model

                Promote pkg ->
                    updateApplication Modify.promotePackage pkg app model
        -- ...

On the JS side, we'll update the script to pass in flags, and actually send multiple commands.

// ... argv parsing and file reading

// Initialize Elm program
const main = Elm.Main.init({
    flags: {
        elmJson,
        strict,
    },
});

// Handle receiving output
main.ports.output.subscribe(console.log);

main.ports.error.subscribe(function (message) {
    console.error(message);
    process.exitCode = 1;
});

// Send messages to Elm
for (const operation of operations) {
    main.ports[operation.command].send(operation.package);
}
main.ports.finish.send(null);