Decoding Untrusted JSON in Elm: Where Decoders Earn Their Keep
Use Elm's Json.Decode and Pipeline to parse untrusted API data safely, keep decode failures in your type system, and avoid the null-versus-missing trap.
19 Apr 2026, 07:03 UTC

The network boundary is where Elm’s guarantees end
Elm eliminates a whole class of runtime crashes, but that protection stops at the edge of your application. When you fetch data from an API you do not control, the bytes arriving over the wire can contain missing fields, unexpected nulls, or wrong types. If you parse that data with Json.Decode, you keep the safety inside your program. The practical takeaway: treat every external payload as untrusted and write a decoder that either produces a typed Elm value or a Decode.Error—never a crash.
Centralize rejection at the HTTP layer
Elm’s Http module accepts a decoder directly through Http.expectJson. That means a malformed payload is rejected before it ever reaches your update function. Your Msg carries a Result, so the failure path is visible in your type system rather than hidden in a runtime exception.
Pipeline keeps record decoders readable
When a JSON object maps to an Elm record with many fields, chaining map8 or map9 quickly becomes unreadable. Json.Decode.Pipeline solves this by letting you compose small decoders with required, optional, and hardcoded. Each step describes one field, and the order matches the record constructor.
A worked example
Suppose an API returns a user profile. Some fields are always present, one has a sensible default when absent, one may be omitted or null, and one is a constant you do not need to read from the payload.
import Json.Decode as Decode
import Json.Decode.Pipeline exposing (hardcoded, maybe, optional, required)
type alias ApiUser =
{ id : Int
, displayName : String
, email : String
, phone : Maybe String
, source : String
}
apiUserDecoder : Decode.Decoder ApiUser
apiUserDecoder =
Decode.succeed ApiUser
|> required "id" Decode.int
|> required "displayName" Decode.string
|> optional "email" Decode.string "[contact removed]"
|> maybe "phone" Decode.string
|> hardcoded "api"
In this decoder, required fails if the key is missing or the value has the wrong type. optional supplies the default only when the key is absent; if the key is present but null, the inner decoder runs and fails. maybe produces a Maybe value, which is useful when the API may omit the key or send an explicit null. hardcoded injects a constant without reading the JSON.
The null-versus-missing trap
The subtle difference between optional and maybe is a common source of bugs. If your API sometimes sends "phone": null and sometimes omits "phone" entirely, optional "phone" Decode.string "..." will fail on the null because Decode.string cannot decode null. Using maybe "phone" Decode.string handles both cases by yielding Nothing. Verify the exact semantics against your API’s real responses, because the behavior differs between missing keys and explicit nulls.
Limitations you should plan for
- Decode.Error is opaque. It is designed for debugging, not for end users.
Decode.errorToStringcan help you diagnose a failure in the REPL or a test, but do not surface that string in your UI. - Decoders are not reversible. A decoder describes how to read JSON, not how to write it. If you need to send data back, you must write an explicit
Json.Encodevalue; do not assume a decoder implies a symmetric encoder. - Package APIs evolve. The functions in
elm/jsoncan change between releases. Confirm that thePipelinefunctions you rely on are present in the version pinned in yourelm.json.
Check the result before you ship
You can verify a decoder quickly in the REPL. Run this from your project root (or any directory where elm is installed):
elm repl
Then paste an import and a decode call, replacing the JSON string with a representative payload from your API:
import Json.Decode as Decode
Decode.decodeString (Decode.field "id" Decode.int) "{\"id\": 1}"
Expected check: the result should be Ok 1 for valid input and Err ... for malformed input. If the REPL is unavailable, add an elm-test case that decodes a real payload into your record and asserts that a malformed variant returns Err. Finally, open elm.json in your project root and note the elm/json version so you can compare the available Pipeline functions against that release’s documentation.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.