Blog › ICP guides

Elm developer on retainer: The Elm Architecture, Json.Decode decoder design, ports, routing, and frontend functional programming on monthly retainer

October 14, 2026 · ~18 min read

An Elm SPA serving a project management dashboard for 150 users had accumulated decoder failures on nested API responses. The front-end logged 4–8 Json.Decode.oneOf failures per minute during peak hours, each silently falling back to empty state and presenting blank sections to users. The Elm developer on retainer diagnosed the root cause: the decoder for nested ProjectConfig objects was using Json.Decode.at ["config", "settings", "permissions"] where the API sometimes returned the permissions field as null (explicitly set) and sometimes omitted it entirely. Json.Decode.at treats both cases identically — both fail — when the final path segment is missing or null. The decoder was restructured using Json.Decode.field "config" (Json.Decode.field "settings" (Json.Decode.maybe (Json.Decode.field "permissions" permissionsDecoder))) — the maybe wrapping distinguishes null (returns Nothing) from missing (the field decoder fails up the chain, correctly surfacing the error rather than silently returning empty state). Decoder failures: weekly count from 300+ → 0.

The work log entry read “fixed decoder failures on ProjectConfig, 9h.” It names the symptom and the duration, leaving the client unable to explain to stakeholders why 150 users were seeing blank dashboard sections, why the fix required restructuring 14 decoders rather than patching one, or what the difference between Json.Decode.at and nested Json.Decode.field calls means for production reliability. The diagnosis required understanding that Json.Decode.at is syntactic sugar over nested field calls but with no provision for inserting maybe at an intermediate path level — the moment you need to distinguish null from absent at any depth in the path, at is no longer the right tool. The 9 hours of decoder audit (identifying which decoders used at on nullable fields), path analysis (determining at which nesting depth the maybe wrapper needed to be inserted for each decoder), restructuring (rewriting 14 decoders to use nested field calls with maybe at the correct level), and regression testing (verifying that the restructured decoders correctly handled all four cases: field present with a value, field present as null, field absent from parent object, and parent object absent) are not visible in the diff beyond the 14 restructured decoder definitions. The blank sections: gone. The weekly decoder failure count: zero.

The Elm Architecture: Model, update, view, and the effect system

The Elm Architecture (TEA) is the mandatory application structure for every Elm program: a Model type representing all application state, a Msg type enumerating all events that can change state, an update : Msg -> Model -> (Model, Cmd Msg) function that processes each event and optionally issues side effects, a view : Model -> Html Msg function that renders the current state as a virtual DOM tree, and an init : flags -> (Model, Cmd Msg) function that constructs the initial state. Browser.sandbox is the simplest entry point: no side effects, no subscriptions, update : Msg -> Model -> Model returns only the new model. Browser.element adds Cmd Msg to the update return type (enabling HTTP requests, random number generation, and port commands) and adds a subscriptions : Model -> Sub Msg field. Browser.document extends Browser.element by giving the Elm program control over the full page <title> and <body> rather than a single DOM node. Browser.application is the full SPA entry point: it adds onUrlChange : Url -> Msg and onUrlRequest : UrlRequest -> Msg fields, giving the Elm program control over URL navigation.

Cmd Msg and Sub Msg are the two effect types in Elm. A Cmd is a description of a side effect to perform — send an HTTP request, generate a random number, write to a port, get the current time. The Elm runtime executes the command and sends the resulting Msg back to update. Commands are values: they cannot be executed by user code, only by the runtime, which means side effects are always explicit and always produce messages that go through the update function. A Sub is a description of an ongoing event source to listen to — incoming port messages, timer ticks, keyboard events, window resize events. Cmd.batch combines multiple commands into one; Sub.batch combines multiple subscriptions. Html.map : (a -> b) -> Html a -> Html b transforms the message type of a subtree, enabling child component views that produce their own message type to be embedded in a parent view that wraps those messages in a parent Msg constructor. Cmd.map and Sub.map do the same for commands and subscriptions respectively, completing the pattern for composing TEA components.

Html.Keyed and Html.Lazy are the two virtual DOM optimization primitives. Html.Keyed.ul : List (Html.Attribute msg) -> List (String, Html msg) -> Html msg takes a list of (key, node) pairs; the Elm runtime uses the key to match nodes across renders, so that when a list item moves position or is removed, the runtime reuses the existing DOM node rather than recreating it. Without keyed nodes, inserting an item at the top of a 100-item list causes 100 DOM node updates (each node shifts its content down by one); with keyed nodes, only one new DOM node is created and the rest are moved. Html.Lazy.lazy : (a -> Html msg) -> a -> Html msg memoizes a render function: if the argument is the same value (by reference equality) as the previous render, the Elm runtime skips calling the function entirely and reuses the previous virtual DOM subtree. A retainer engagement auditing virtual DOM performance involves identifying which subtrees are being fully recomputed on every update call even when their underlying data has not changed — typically large list renders, heavy chart or table components, and sidebar navigation trees — and wrapping them with Html.Lazy.lazy or migrating list renders to Html.Keyed node variants.

Task is Elm’s abstraction for sequenceable side effects. Task.perform : (a -> msg) -> Task Never a -> Cmd msg runs a task that cannot fail and sends the result as a message. Task.attempt : (Result e a -> msg) -> Task e a -> Cmd msg runs a task that can fail. Task.andThen : (a -> Task e b) -> Task e a -> Task e b sequences two tasks: the first task’s result is passed to a function that returns the second task, enabling multi-step workflows (get the current time, then construct a request with a timestamp, then send it) without nesting callbacks. Http.toTask converts an Http.Request into a Task Http.Error a, enabling HTTP requests to be chained with Task.andThen. Http.Error has five variants: BadUrl (the URL string was malformed), Timeout (the request exceeded its timeout), NetworkError (no connectivity), BadStatus (the server responded with a non-2xx status), and BadBody (the response body failed to decode). A retainer engagement designing error handling architecture involves ensuring every Http.Error variant is handled explicitly in the update function, with distinct user-visible error states for transient errors (timeout, network) versus permanent errors (bad status, bad body) versus programming errors (bad URL).

Json.Decode pipeline design, ports, routing, and elm-ui

Json.Decode.Decoder a is a composable parser for JSON values: it either succeeds with a value of type a or fails with an error describing what went wrong. Json.Decode.decodeString : Decoder a -> String -> Result Json.Decode.Error a runs a decoder against a JSON string; Json.Decode.decodeValue : Decoder a -> Value -> Result Json.Decode.Error a runs it against a Json.Decode.Value (a pre-parsed JavaScript value passed through a port). field : String -> Decoder a -> Decoder a expects a JSON object with a specific key; at : List String -> Decoder a -> Decoder a nests multiple field calls. index : Int -> Decoder a -> Decoder a decodes a specific position in a JSON array. map2 through map8 combine multiple field decoders into a record or constructor: map3 ProjectConfig (field "id" int) (field "name" string) (field "created" posixDecoder). andThen : (a -> Decoder b) -> Decoder a -> Decoder b enables dependent decoding: field "type" string |> andThen (\t -> case t of "user" -> userDecoder; "org" -> orgDecoder; _ -> Json.Decode.fail ("Unknown type: " ++ t)) decodes the discriminant field first and selects the appropriate branch decoder. oneOf : List (Decoder a) -> Decoder a tries each decoder in order and returns the first success; maybe : Decoder a -> Decoder (Maybe a) wraps a decoder so that a null JSON value returns Nothing instead of failing. Json.Decode.value : Decoder Value delays decoding by returning the raw Value, enabling deferred or dynamic decoding. Json.Decode.errorToString : Error -> String converts a decode error into a human-readable string for logging or UI display.

Port modules are the mechanism for Elm to communicate with JavaScript. A module declared as port module Main exposing (..) can define ports. An outgoing port sends data from Elm to JavaScript: port toJS : String -> Cmd msg; calling toJS "hello" in an update function sends the string to any JavaScript handler subscribed to that port. An incoming port receives data from JavaScript into Elm: port fromJS : (String -> msg) -> Sub msg; subscribing fromJS GotFromJS in the subscriptions function means that any call to the port’s send method from JavaScript will produce a GotFromJS message. In practice, ports use Json.Decode.Value as the payload type for flexibility: port fromLocalStorage : (Value -> msg) -> Sub msg accepts any JSON value and the Elm side decodes it with a Json.Decode.decodeValue call. Common port patterns in production Elm SPAs: localStorage read/write (JavaScript reads localStorage on page load and sends the stored JSON blob through an incoming port; Elm encodes model changes as JSON and sends them through an outgoing port to be stored); WebSocket management (JavaScript manages the WebSocket connection lifecycle and forwards messages through incoming ports); analytics event emission (Elm sends structured event objects through an outgoing port; JavaScript calls the analytics SDK); third-party widget initialization (Elm signals readiness through an outgoing port; JavaScript mounts a chart or map library into a DOM node that Elm has left empty via a div [] placeholder).

Browser.application routing requires implementing two message handlers. onUrlRequest : UrlRequest -> Msg is called when the user clicks a link; a UrlRequest is either Internal Url (a link within the application domain) or External String (a link to another domain). The handler for Internal requests calls Navigation.pushUrl key (Url.toString url) to update the browser history without a full page reload; the handler for External requests calls Navigation.load href to navigate away. onUrlChange : Url -> Msg is called after the URL changes (whether from a pushUrl, the back button, or initial page load); its handler parses the URL using Url.Parser.parse and transitions the model to the appropriate page state. Url.Parser combinators: s "dashboard" matches the literal path segment “dashboard”; int matches a numeric path segment and binds it; string matches any path segment; (</>) sequences two parsers; top matches the root path. Query parameters: Url.Parser.Query.string "tab" parses an optional query parameter; (<?>) appends query parameter parsing to a path parser. A retainer engagement migrating a hash-based single-page app to Browser.application typically involves designing the Page union type to cover all routes, implementing the Url.Parser route tree, threading the Navigation.Key through the model for programmatic navigation, and ensuring UrlChanged and LinkClicked messages are handled in the update function for every route variant.

elm-ui replaces the HTML/CSS layout model with a composable layout system built on Element. row : List (Attribute msg) -> List (Element msg) -> Element msg lays out children horizontally; column lays them out vertically; el : List (Attribute msg) -> Element msg -> Element msg wraps a single child. text : String -> Element msg renders a text node. Layout attributes: spacing 16 sets the gap between children; padding 24 sets the internal padding; width fill makes an element expand to fill available horizontal space; height fill for vertical. Element.Font provides Font.size, Font.weight, Font.color, and Font.family; Element.Background provides Background.color and Background.image; Element.Border provides Border.rounded, Border.width, and Border.color. Element.Input provides accessible form controls: Input.text, Input.checkbox, Input.radio, and Input.button. Element.explain Debug.todo adds a visual debug overlay showing the layout boxes for all elements in the subtree — the elm-ui equivalent of adding a red border to every element to debug layout issues. Responsive layout in elm-ui uses Element.classifyDevice to inspect the window size and return a Device record, then conditionally applies different layout attributes or switches between row and column based on the device class.

How HourTab tracks Elm developer retainer hours

Elm retainer work shares the invisible-work problem with all functional programming retainers, amplified by the fact that Elm’s most common retainer tasks — decoder restructuring, routing implementation, port module design, Html.Keyed migration — involve architectural changes that produce small diffs. Restructuring 14 decoders to replace Json.Decode.at with nested Json.Decode.field and maybe is a diff whose surface area is 14 changed function bodies, but whose value is eliminating 300+ decoder failures per week and the blank dashboard sections they produced for 150 users. Implementing Browser.application routing with a 14-route Url.Parser tree is a diff that adds one new file and modifies the main module, but whose value is correct back-button behavior, bookmarkable URLs, and server-side rendering compatibility. Designing 5 port modules for localStorage, WebSocket, analytics, clipboard, and charting is a diff that adds 10 port declarations and 5 JavaScript handler implementations, but whose value is a clean interop boundary that prevents JavaScript runtime errors from propagating into the Elm runtime. The architectural analysis, the decoder path tracing, the Url.Parser combinator design, the port protocol design — none of these have artifacts proportional to their complexity in the committed diff.

HourTab gives Elm developers a public retainer-hours URL they send to clients — typically product teams running Elm SPAs, design system teams maintaining elm-ui component libraries, or frontend teams migrating a React codebase to Elm — at the start of an engagement. For Elm retainers, each work log entry should name the mechanism (Json.Decode pipeline restructuring; andThen union type decoder design; maybe null vs missing field distinction; Browser.application routing implementation; Url.Parser route tree design; port module declaration and JS interop handler implementation; Html.Keyed migration for stable-identity list nodes; Html.Lazy.lazy wrapping for expensive subtrees; elm-review rule authorship; elm-test Fuzz property test authorship; Http.Error variant handling; Task.andThen effect sequencing; elm make --optimize build configuration), the specific decoder name and the failure mode, and the before/after observable metric. Elm retainers are often compared to PureScript developer retainers for compile-to-JavaScript functional programming work, to F# developer retainers for ML-family language frontend work, and to Haskell developer retainers for the same type-driven approach applied server-side. The distinction from PureScript is that Elm enforces a single application architecture (TEA) with no escape hatches — no type classes, no higher-kinded types, no unsafePerformEffect — which makes Elm codebases highly uniform and the retainer work more predictable, but means the developer on retainer must be fluent in the specific idioms of TEA-based component design rather than general Haskell-style abstractions. HourTab’s work log bridges the gap: the entry names the decoder, the failure mode, the restructuring decision, and the before/after production metric, so the client understands what the retainer accomplished without needing to know why Json.Decode.at and Json.Decode.field behave differently on null values.

Track Elm developer retainer hours without the status emails

HourTab gives Elm developers a public URL per client retainer. One link, no login, live burn-down. Your clients stop asking “how many hours do I have left?” and your work log becomes the proof of value that gets the retainer renewed.

See HourTab pricing →

FAQ: Elm developer retainers

What does an Elm developer on retainer typically do?

An Elm developer on monthly retainer covers four principal service areas: Json.Decode pipeline design and maintenance (decoder audit for null vs missing field failures; andThen-based union type decoder design; oneOf branch ordering; maybe wrapping at the correct nesting depth; Json.Decode.errorToString surfacing in UI error boundaries; Json.Encode for outgoing JSON); Browser.application routing (route tree design with Url.Parser combinators; onUrlChange and onUrlRequest handler implementation; Navigation.pushUrl and replaceUrl; query parameter parsing; UrlChanged and LinkClicked message handling); port module design for JavaScript interop (outgoing and incoming port declaration; localStorage, WebSocket, analytics, and third-party widget bridge implementation; Subscriptions function composition; port protocol versioning); and Html.Keyed and Html.Lazy optimization (virtual DOM reconciliation auditing; keyed node migration for stable-identity lists; lazy wrapping for expensive subtrees; Html.map for child component view embedding). Supporting work includes elm-ui layout design, elm-review rule authorship, elm-test Fuzz property testing, Http.Error variant handling, Task.andThen effect sequencing, and elm make --optimize production build configuration.

What Elm work is most commonly underlogged in a retainer?

Json.Decode pipeline restructuring (300+ decoder failures/week on a project management dashboard — all from Json.Decode.at treating null and missing identically; restructured using nested field calls with maybe at the permissions level; decoder failures: 300+/week → 0; 8–18 hrs invisible in decoder architecture analysis across 14 decoders), Browser.application routing migration (migrating from hash-based Browser.element to Browser.application with Url.Parser, onUrlChange, and onUrlRequest; route coverage: 6 → 14 routes; back-button correctness: broken → correct; 12–28 hrs invisible in Url.Parser combinator design and message handling), port module design (5 ports for localStorage, WebSocket, analytics, clipboard, charting; port protocol design — what to encode as Value vs typed alias — consumed 6–14 hrs invisible in the diff, which shows only 5 port declarations and their JS handlers), and elm-review rule authorship (3 custom rules enforcing decoder error handling conventions, prohibiting Debug.log in production, flagging unchecked Http.Error variants; enforcement failures/week: 11 → 0; 14–30 hrs invisible in AST pattern matching design and test case authorship).

What are typical Elm developer retainer rates?

Entry-level Elm developers (1–2 years, Browser.sandbox and Browser.element, basic Json.Decode field and map2–map8 composition, Http.get and Http.expectJson, simple Url.Parser routes) bill at $70–$125/hr. Mid-level Elm engineers (2–4 years, Browser.application routing, andThen-based decoder design for discriminated union types, port module architecture for multi-channel JS interop, Html.Keyed and Html.Lazy optimization, elm-review custom rule authorship, elm-test Fuzz property testing) bill at $115–$210/hr. Senior Elm architects (4–8 years, full SPA architecture with complex routing trees and nested page components, decoder error boundary design, Task.andThen effect sequencing for multi-step API workflows, elm-ui layout system design for large dashboard applications, elm-spa scaffolding, elm make --optimize production build pipeline configuration) bill at $170–$310/hr. Monthly retainer ranges: $2,500–$5,800/mo for advisory retainers (15–25 hrs), $8,000–$21,000/mo for full frontend functional programming engagements.

What should an Elm developer retainer agreement include?

An Elm developer retainer agreement should specify: Json.Decode scope (decoder audit for null vs missing field distinctions; andThen-based union type decoder design; oneOf branch ordering; Json.Decode.errorToString surfacing; Json.Encode for outgoing JSON); routing scope (Browser.application migration from Browser.element; Url.Parser route tree design; onUrlChange and onUrlRequest handler implementation; Navigation.pushUrl and replaceUrl; query parameter parsing); ports scope (port module declaration; outgoing and incoming port type design; JS interop handler implementation for localStorage/WebSocket/analytics; port protocol versioning; Subscriptions function composition); Html optimization scope (Html.Keyed migration for stable-identity lists; Html.Lazy.lazy wrapping for expensive subtrees; Html.map for child component view embedding; virtual DOM reconciliation auditing); elm-ui scope (Element layout design with row/column/el; Input widget implementation; Font/Background/Border attribute composition; responsive layout with width fill and height fill; Element.explain Debug.todo layout debugging); tooling scope (elm-format configuration; elm-review rule authorship and enforcement; elm-test Fuzz property test authorship; elm make --optimize production build configuration); and hour logging format (decoder name; failure mode; restructuring strategy; before/after failure count; Elm version and dependency versions).

How should Elm developer retainer hours be logged?

Log each Elm retainer session with: advisory category (Json.Decode pipeline restructuring; andThen union type decoder design; maybe null vs missing field distinction; Browser.application routing implementation; Url.Parser route tree design; onUrlChange and onUrlRequest handler authorship; port module declaration and JS interop handler implementation; Html.Keyed migration for stable-identity list nodes; Html.Lazy.lazy wrapping for expensive subtrees; Html.map child component embedding; elm-ui layout design; elm-review rule authorship; elm-test Fuzz property test authorship; Http.Error variant handling; Task.andThen effect sequencing; elm make --optimize build configuration), the specific decoder name and failure mode (Json.Decode.at ["config", "settings", "permissions"] — both null and missing fail identically; restructured using nested field calls with maybe wrapping at the permissions level to return Nothing for null), and the before/after observable metric (decoder failures/week: 300+ → 0; back-button correctness: broken → correct; virtual DOM node recreation on list update: full list → keyed diff; elm-review enforcement failures/week: 11 → 0). Include Elm version, elm-format version, elm-review version, and elm-test version. For port work, log each port by name, the JavaScript event or API it bridges, and the message type it produces in the Elm runtime. For routing work, log the route count before and after and the Url.Parser combinators used.