Building Robust JSON Decoders in Elm 0.19
Learn how to build type-safe JSON decoders in Elm 0.19 using elm/json and the decode-pipeline package to handle nullable fields and large API responses without runtime crashes.
03 Nov 2025, 09:24 UTC

The Problem: Unpredictable API Payloads
Integrating external APIs often introduces runtime instability when the backend returns unexpected null values, missing keys, or type mismatches. In languages with dynamic typing, these issues manifest as runtime crashes. In Elm 0.19, the elm/json package prevents crashes by forcing you to explicitly define how JSON maps to your types. If the data doesn't match your decoder, you get a DecodeError instead of a crash, but the challenge lies in handling large records and nullable fields without writing verbose, repetitive code.
Prerequisites
- Elm 0.19.1 installed.
- A project initialized via
elm init. - The core JSON package installed:
elm install elm/json. - For records with more than 8 fields, install
elm install NoRedInk/elm-json-decode-pipelineto bypass themap8limitation.
Implementing a Safe Decoder
The goal is to move data from a raw JSON string into a Transport Type (which mirrors the API) and then into a Domain Model (which mirrors your application logic). This separation ensures that invalid API states cannot leak into your business logic.
Step 1: Define the Transport Model
Use Maybe for any field that the API documentation marks as optional or that could return null.
type alias UserApi =
{ id : Int
, username : String
, email : Maybe String
, bio : Maybe String
}
Step 2: Build the Decoder
Use Json.Decode.field to target keys and Json.Decode.nullable to handle null values. For records up to 8 fields, map4 (or similar) is the standard approach.
import Json.Decode as Decode
userDecoder : Decode.Decoder UserApi
userDecoder =
Decode.map4 UserApi
(Decode.field "id" Decode.int)
(Decode.field "username" Decode.string)
(Decode.field "email" (Decode.nullable Decode.string))
(Decode.field "bio" (Decode.nullable Decode.string))
Step 3: Handling Large Records (9+ Fields)
Because elm/json limits its mapping functions to 8 arguments, use the elm-json-decode-pipeline package to chain decoders linearly.
import Json.Decode.Pipeline as Pipeline
-- Example for a large record
largeRecordDecoder : Decode.Decoder LargeRecord
largeRecordDecoder =
Pipeline.required "id" Decode.int
|> Pipeline.required "name" Decode.string
|> Pipeline.optional "age" Decode.int
|> Pipeline.required "email" Decode.string
-- ... add as many as needed
|> Pipeline.build LargeRecord
Integration with Http.get
When using elm/http, pass your decoder to expectJson. This converts decoding failures into a BadBody error, which you must handle in your update function.
-- In your update function
case result of
Ok user ->
-- Success: Update state with user
Err (Http.BadBody error) ->
-- Failure: The API returned JSON that didn't match the decoder
-- Action: Log the error and show a "Data Format Error" message to the user
Err _ ->
-- Other network errors
-- Action: Trigger a retry mechanism
Comparison: Nullable vs. Optional
| Scenario | Decoder Approach | Resulting Type | Behavior |
|---|---|---|---|
Key exists, value is null |
Decode.nullable Decode.string |
Maybe String |
Returns Just "val" or Nothing |
| Key is missing entirely | Decode.oneOf [ Decode.field "k" ..., Decode.constant Nothing ] |
Maybe String |
Falls back to Nothing if key is missing |
Verification and Testing
To verify your decoder without running the full application, use the elm repl:
- Start
elm replin your project root. - Define a sample JSON string:
let json = "{\"id\": 1, \"username\": \"elm_user\", \"email\": null, \"bio\": null}" - Run the decoder:
Json.Decode.decodeString userDecoder json - Expected Result:
Ok { id = 1, username = "elm_user", email = Nothing, bio = Nothing }
Diagnostic Checks
- Check for Order: When using
oneOf, place the most specific decoder (the one most likely to fail) first. If a genericDecode.constantis first, it will always match, and your specific logic will never run. - Check for Types: If you receive a
BadBodyerror, check if the API returned a number as a string (e.g.,"123"instead of123). You may need to decode as a string and then convert to an int usingString.toInt.
Limitations
The elm/json library is strictly for Elm 0.19. If you are migrating from 0.18, the function names have changed significantly. Additionally, very large nested payloads decoded with deep oneOf branches can impact performance; for complex polymorphic responses, use a "discriminator" field (e.g., "type": "admin") to route the decoding to a specific sub-decoder.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.