Elm's JSON Decoders: Making Bad API Data a Problem You Handle, Not a Crash You Debug
Elm forces all external JSON through explicit, composable decoders that return a Result. A worked example shows how this turns API contract drift into handled errors instead of production crashes — and where the boilerplate starts to cost you.
16 Jul 2026, 04:22 UTC

Every frontend that talks to a REST API eventually gets burned by the same thing: a payload that doesn't match what the code assumed. A field renamed server-side, a null where you expected a string, a number that arrives as a string. In most languages this surfaces as a runtime exception in production. Elm takes a different position: external data is untrusted by definition, so the language makes you write an explicit decoder before any JSON can touch your application's types.
That sounds like ceremony, and honestly it is some. But it converts a whole class of production bugs into a Result you handle in your update loop, next to all your other error handling. This post walks through how that works with a concrete example, where the boilerplate starts to hurt, and how to keep decoders maintainable.
Decoders mirror your types
A decoder in Elm (from the elm/json package) is a value that describes how to turn raw JSON into an Elm type. You build small decoders for individual fields and compose them so the decoding code mirrors the shape of your target type. Say your API returns user records:
type alias User =
{ id : Int
, name : String
, email : Maybe String
}
userDecoder : Decoder User
userDecoder =
Decode.map3 User
(Decode.field "id" Decode.int)
(Decode.field "name" Decode.string)
(Decode.maybe (Decode.field "email" Decode.string))Three things are worth noticing. First, Decode.map3 combines three field decoders and feeds their results into the User constructor, in order. Second, Decode.maybe wraps the email field so a missing key or a null becomes Nothing instead of a failure — essential when consuming third-party APIs with inconsistent payloads. There's also Decode.nullable if you want null to succeed but a missing key to fail; the distinction matters more often than you'd think. Third, nothing here is optional. If the API sends "id": "abc", decoding fails — it does not silently coerce.
Failure is a Result, not an exception
Decoding produces a Result Error User. In practice you run it at the boundary — when an HTTP response arrives — and route the failure through your model like any other state:
case Decode.decodeString userDecoder responseBody of
Ok user ->
( { model | user = Just user }, Cmd.none )
Err decodeError ->
( { model | error = Just (Decode.errorToString decodeError) }
, Cmd.none
)Decode.errorToString (available in elm/json 1.1.0 and later — check your elm.json, because 1.0.0 doesn't have it) produces a detailed, path-aware message like "Problem with the value at json.name: expected a String but got a Number." That message is usually enough to spot a contract drift between the API and your Elm types without adding logging infrastructure.
The compiler also helps before you ever run anything. If you change User.id to a String but leave the decoder using Decode.int, the type mismatch is caught at compile time, because the decoder's type must line up with Decoder User.
Keep decoders at the boundary
The engineering decision that pays off most: decode into domain types immediately, in the module that owns the API call, and never pass raw JSON strings inward. If your update or view code ever pattern-matches on raw JSON, the decoder boundary has leaked and you've lost the guarantee. A common layout is one module per API resource (Api.User) exposing the domain type, its decoder, and the HTTP request function, so the contract lives in exactly one place. When the API changes, you fix one decoder and let the compiler walk you through every affected call site.
The honest trade-off: boilerplate
The cost is real. map3 scales poorly — elm/json provides map through map8, and past that you reach for pipeline-style decoding with Json.Decode.Pipeline (from NoRedInk/elm-json-decode-pipeline) using required and optional. For very large payloads, decoders get long and mechanical, and code generation tools exist but add build complexity and their own failure modes. There's also a broader caveat: Elm's release cadence is slow — 0.19.1 has been current since 2019 — so weigh ecosystem momentum before adopting it for a new production project.
Try it and check the failure path
The fastest way to build intuition is to make decoding fail on purpose. In a minimal Elm 0.19.1 project, decode a deliberately malformed payload — say, { "id": "oops", "name": "Ada" } against the decoder above — and print Decode.errorToString of the result. Confirm two things: you get an Err carrying a useful message rather than a crash, and removing the Decode.maybe wrapper turns a missing email key into a hard failure. If both behave as described, you've seen the whole value proposition in five minutes: the unhappy path is a first-class, testable part of your program, not a surprise.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.