Elm's JSON Decoders: Turning Runtime Guesses Into Compile-Time Contracts
Elm makes you write JSON decoders by hand — and that's the point. How composable decoders turn runtime parsing surprises into explicit, testable contracts.
24 May 2026, 17:41 UTC

The problem: JSON is a lie until proven otherwise
In most languages, parsing JSON is an act of faith. You write JSON.parse(body), cast the result to a type, and hope the server actually sent what you expect. When it didn't, you find out in production, usually as undefined is not a function deep inside a render function.
Elm takes a deliberately unfashionable position: there is no automatic JSON parsing. If you want a User record, you write a decoder — a small, pure function that describes exactly what shape the data must have. It feels like extra work for about a day. Then you realize you've moved an entire class of bugs from "runtime surprise" to "explicit, testable code."
Decoders are values, not magic
A decoder in Elm is just a value of type Decoder a — a description of how to turn a raw Json.Value into some Elm type a. The elm/json package gives you primitives like string, int, and field, plus combinators like map2, andThen, and oneOf to build bigger decoders from smaller ones.
Here's a concrete example. Suppose your API returns a user object:
{ "id": 42, "name": "Ada", "email": null }The matching Elm code, assuming Elm 0.19 with elm/json installed:
import Json.Decode as Decode exposing (Decoder, int, string)
import Json.Decode.Pipeline exposing (optional, required)
type alias User =
{ id : Int
, name : String
, email : Maybe String
}
userDecoder : Decoder User
userDecoder =
Decode.succeed User
|> required "id" int
|> required "name" string
|> optional "email" (Decode.nullable string) Nothing(The pipeline helpers come from the community package NoRedInk/elm-json-decode-pipeline; you can write the same thing with plain map3 if you prefer fewer dependencies.)
Two things are worth noticing. First, email is Maybe String — the decoder forces you to decide, in code, what happens when a field is missing or null. There is no accidental undefined. Second, if the JSON doesn't match — say id arrives as a string — you don't get a crash. You get a Decode.Error value describing what went wrong and where, which your program must handle explicitly.
Failure is a value you have to deal with
Decoding happens with Decode.decodeString or Decode.decodeValue, both of which return a Result:
case Decode.decodeString userDecoder rawJson of
Ok user ->
-- use the fully typed User record
Err decodeError ->
-- log Decode.errorToString decodeError, show a fallback, retry...This is the heart of the design. The compiler will not let you ignore the Err branch. In an HTTP app, that typically means your Msg type looks like GotUser (Result Http.Error User), and the failure path is a first-class state in your model — an error banner, a retry button — rather than an uncaught exception. Elm's famous "no runtime exceptions in practice" claim rests heavily on this pattern: the dangerous boundary (the network) is wrapped in a type that makes failure impossible to forget.
One contract, reused everywhere
Because decoders are ordinary values, the same userDecoder works for an HTTP response, data passed in through flags at startup, messages arriving over a port from JavaScript, or a WebSocket payload. Your data contract lives in exactly one place. When the API changes, you change one decoder and the compiler points at everything affected.
They're also trivially testable with elm-test, since they're pure functions:
test "missing email decodes to Nothing" <|
\_ ->
"{\"id\": 1, \"name\": \"Ada\", \"email\": null}"
|> Decode.decodeString userDecoder
|> Expect.equal (Ok { id = 1, name = "Ada", email = Nothing })You can verify this whole workflow yourself in minutes: run elm init, elm install elm/json, paste the decoder into elm repl, and feed it malformed JSON. You'll get a descriptive Err — never an exception. That quick REPL session is the fastest way to build intuition for how combinators behave.
The honest trade-offs
Decoder verbosity is real. A deeply nested API response can produce a wall of required calls, and decoders for union types (JSON where a type field determines the shape) require andThen gymnastics that newcomers find awkward. The standard mitigations help: extract small decoders for nested objects and compose them, and keep your Elm types close to the JSON shape rather than decoding directly into a heavily normalized domain model in one step.
There's also a performance angle: long chains of andThen allocate intermediate results, and decoding very large payloads on every frame can show up in profiles. In practice this rarely matters for typical request/response apps, but if you're streaming large datasets through ports, benchmark before reaching for cleverness.
Finally, decoders validate structure, not semantics. A decoder confirms age is an Int; it won't tell you the API sent -5. For that, add validation after decoding or use andThen with Decode.fail to reject impossible values at the boundary.
Where to start
Pick one HTTP call in an existing Elm app — or a small side project — and write its decoder by hand instead of generating it. Then write two tests: one with valid JSON, one with a field of the wrong type. Watching the wrong-type case produce a clean, inspectable Decode.Error instead of a stack trace is the moment the design clicks. The boilerplate is the price; the contract is the payoff.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.