/ Compiler says no!

Elm and the art of limitations

Putting the “art” in “artisanal code”.

Elm has become much dearer to me than I would have imagined, although for many it has become a cautionary tale, mainly around governance. For those not in the loop, it is a delightful little functional language designed solely for the browser, compiling to JavaScript.

There is much to love about it: its small compiled output works in all browsers without any WASM-antics, and the language itself makes it really easy to write correct code. You get time-travel debugging out of the box; an application model dubbed TEA (“The Elm Architecture”), so solid that dozens of projects in other languages still attempt to copy it; and remarkable performance.

On its darker side, it really managed to emphasize both the B and the D in open source’s BDFL development model. After removing some crucial features from the language in the jump from version 0.18 to 0.19, the primary author and effectively sole owner left everyone else on read shortly afterward. To add insult to injury, safeguards had also been added to the compiler to make it harder for others to reimplement the missing features. Following the bug-fix release of 0.19.1 in October 2019, Elm went more than six and a half years without another release, until 0.19.2 arrived in July 2026 with no language changes.

As a result, Elm, which briefly enjoyed hype rivaling that of Rust and other interesting bits of tech on sites like Hacker News, sort of fizzled out until only zealots die-hard fans remained, often insisting that “it’s not dead, it’s just stable.” While technically correct, this sounds at best like a dark joke a cardiologist would make about a flatlining patient’s EKG.

Don’t get me wrong, I’m a firm believer in the principle that open source maintainers don’t owe anyone anything at all, so I harbor absolutely no ill will toward Evan. On the contrary, I’m very grateful for his contribution and the huge influence it had. Rust’s compiler team, for example, explicitly credited Elm as a heavy inspiration for its improved error messages. However, I think it is worthwhile to shed some light on the limitations that make reminiscing about Elm bittersweet.

But… why Elm?

The language usually springs to mind when I have to do some frontend development and dive into the absolute circus that is JavaScript, TypeScript and accomplices. I don’t know why, but Elm was first released in April 2012, more than fourteen years ago, and the JS community still hasn’t managed to produce anything remotely as pleasant to use. Elm’s second-biggest drawback was probably its verbosity, which, in the age of VC-subsidized tokens, is less of an issue. So these days, I’m more than happy to get back on my dead horse.

Elm is a functional, statically typed, pure language. That magical combination makes it easy to write correct code that makes invalid states unrepresentable, which is especially nice in the UI field (for an example from the original wave of Elmphoria, see Kris Jenkins’s “How Elm Slays a UI Antipattern.”). Combined with the aforementioned Elm Architecture model, or TEA, this makes Elm very pleasant to work with.

However, to enforce this, all functions must be deterministic and free of side effects. Something like sending an HTTP request is therefore only possible by producing an explicit command. The command’s eventual result is injected back into your regular message-processing loop by the runtime once the operation is complete. Strict typing also means that you cannot forget to handle that result:

import Http


type Model
    = Loading
    | Loaded String
    | Failed Http.Error


type Msg
    = GotGreeting (Result Http.Error String)


getGreeting : Cmd Msg
getGreeting =
    Http.get
        { url = "/api/greeting"
        , expect = Http.expectString GotGreeting
        }


init : () -> ( Model, Cmd Msg )
init _ =
    ( Loading, getGreeting )


update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        GotGreeting result ->
            case result of
                Ok greeting ->
                    ( Loaded greeting, Cmd.none )

                Err error ->
                    ( Failed error, Cmd.none )

One neat property is that even third-party libraries, short of an infinite loop, cannot crash your application: everything is deterministic, and Elm exposes no exceptions. This also means that embedding JavaScript directly in Elm code is off limits, as it might introduce nondeterminism or violate Elm’s invariants. JavaScript must instead live outside the Elm application and communicate through ports, unless it has already been wrapped inside one of Elm’s privileged libraries.

A Two-Tier Language

Some Elm code is more equal than others: Kernel code, blessed by the compiler, is allowed to map directly to regular JavaScript functions. This distinction is quite literal: the compiler only permits kernel code in packages whose author is elm or elm-explorations.

For example, sin in Elm’s Basics module is defined as:

sin : Float -> Float
sin =
    Elm.Kernel.Basics.sin

That points to the corresponding definition in Elm/Kernel/Basics.js:

var _Basics_sin = Math.sin;

The compiled output then connects the public Elm function directly to that JavaScript binding:

var $elm$core$Basics$sin = _Basics_sin;

If you wanted to add the missing hyperbolic sine function, you would be out of luck. You can implement the formula in pure Elm, but there is no supported way to add the equivalent direct binding to Math.sinh. In fact, the request to add Elm’s missing hyperbolic functions has been open since 2018.

There is one way to partially escape Elm’s permanent underclass: the blessed mechanism of ports. Ports are essentially a message-passing system for sending values between an Elm application and the surrounding JavaScript runtime. A round trip to Math.sinh could look like this on the Elm side:

port module Main exposing (main)

import Platform


port calculateSinh : Float -> Cmd msg


port sinhResult : (Float -> msg) -> Sub msg


type alias Model =
    { result : Maybe Float }


type Msg
    = GotSinh Float


main : Program () Model Msg
main =
    Platform.worker
        { init = \() ->
            ( { result = Nothing }, calculateSinh 1 )
        , update = update
        , subscriptions = \_ -> sinhResult GotSinh
        }


update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        GotSinh result ->
            ( { model | result = Just result }, Cmd.none )

The surrounding JavaScript subscribes to one port and sends the result back through the other:

const app = Elm.Main.init();

app.ports.calculateSinh.subscribe((value) => {
    app.ports.sinhResult.send(Math.sinh(value));
});

Ports Are Not an FFI

Naturally, ports are no replacement for pure, side-effect-free functions, and if you said the code above looks ridiculous, I would agree. They require a full message round trip through Elm’s runtime machinery, making everything more complicated. You do not want sinh to be equivalent to an HTTP request in either ergonomics or performance.

That aside, there are much better applications for ports, like WebSockets! They are a reasonable candidate for the message-passing model, fitting the same command-and-subscription semantics already used for HTTP, the kind of effect Elm’s entire Cmd msg architecture was designed to handle. I myself have published an Elm library called elm-wss, which you will notice requires some JavaScript boilerplate because Elm packages cannot contain JavaScript, and some Elm boilerplate because packages that are published cannot declare ports either.

I’m not mad about this security model. Imagine being able to add a JavaScript dependency in 2026 without immediately exposing yourself to a supply-chain attack!

The elm-wss implementation uses two application-owned ports: wsCmd for sending commands to the JavaScript runtime and wsMsg for receiving events from it:

port module WebsocketPorts exposing (wsCmd, wsMsg)

import WebsocketSimple exposing (CommandPort, EventPort)


port wsCmd : CommandPort msg


port wsMsg : EventPort msg

Connecting to a WebSocket chat server then looks like this:

import WebsocketPorts as Ports
import WebsocketSimple as Ws


type alias Model =
    List String


type Msg
    = WebSocketEvent Ws.RawMsg


init : () -> ( Model, Cmd Msg )
init _ =
    ( []
    , Ws.open Ports.wsCmd "wss://chat.example.com/socket"
    )

Receiving messages is just a matter of adding a subscription:

subscriptions : Model -> Sub Msg
subscriptions _ =
    Sub.map WebSocketEvent (Ws.subscribe Ports.wsMsg)

This setup has one flaw, though: while events can be associated with a socket handle, errors cannot be associated with the specific command that caused them. A general concurrent request-response layer would need correlation metadata on each command and Elm-side state for the pending requests. Restricted designs can avoid that by allowing only one request in flight or routing replies by fixed operation tags, and libraries can hide the bookkeeping inside a larger application model, but doing so in elm-wss would probably belie the “simple” part of its name.

Put differently, an outgoing port is fire-and-forget. Its signature is polymorphic:

type alias CommandPort msg =
    ( String, String, Json.Encode.Value ) -> Cmd msg

That msg is effectively phantom. An outgoing port can be used wherever any particular Cmd Msg is expected, but it never actually produces a msg, and there is no callback or continuation to attach to it. JavaScript can synchronously send a reply through the incoming wsMsg port, but that remains an independent Sub route with no runtime-provided association to the outgoing command.

Elm’s blessed HTTP commands look similar on the surface but have one crucial additional piece. Http.get accepts an Expect msg, and functions such as expectString construct one from a callback:

get : { url : String, expect : Expect msg } -> Cmd msg

expectString : (Result Error String -> msg) -> Expect msg

The command can therefore eventually produce a message containing Result Http.Error String, intrinsically tying that result to the request. A raw outgoing port has no equivalent mechanism. The exposed port API is simply not powerful enough to express request-response correlation directly.

All Things Considered

“The enemy of art is the absence of limitations.”

Orson Welles, as recalled by Henry Jaglom

The apparent staleness (although there was a sign of life with a new release completely out of the blue recently) may be a limitation, but it also encourages clever engineering around it. For elm-wss, that limitation heavily shaped the API, and on suitable greenfield projects it nudges my WebSocket communication toward a truly actor-style model: one without an implicit request-response mechanism. Others have tackled the same problem by recreating effect-manager-like request-response semantics in an Elm library, such as elm-porter. Some may have taken it too far by monkeypatching XMLHttpRequest.prototype.

Elm has always been my comfort food, especially because it invites you to write small things from scratch, and doing so is usually rather quick and enjoyable. It remains the antithesis of the 1,200-package-deep dependency tree you pull in just to render a counter in a “modern” JavaScript framework. With more code being machine-generated, that little bit of code horticulture may be going away (or perhaps not; after all, that’s up to you), but tending the code yourself remains a remarkably grounding experience.

Of course, no matter how often you call it “stable,” Elm is obviously unfinished, but that’s okay. The unexpected 0.19.2 release has even rekindled a little excitement. Perhaps the future holds a few more surprises, such as a return to treating the user like a grown-up and allowing them to write their own kernel functions locally, or ports that are proper effects.

And if not… you can still do what everyone else does, which is to fork the compiler.