Diagnosing Elm JSON Decoder Failures: From "Problem with the given value" to a Fixed Decoder
Elm decode failures are Err values, not exceptions. Capture the raw JSON, reproduce the exact failing path in a test, then fix the decoder that no longer matches the payload.
26 Jul 2026, 02:52 UTC

The symptom: compiles clean, fails at runtime
Your Elm app builds, the HTTP request returns a successful status, and then the UI shows an empty list — or your test output contains a message beginning Problem with the value at json.items[2].id. In Elm 0.19.x a JSON decode failure is not an exception. Decode.decodeString and Http.expectJson produce an Err value. Code that maps every Err to an empty model turns a schema mismatch into silent data loss.
The useful takeaway: capture the raw payload, reproduce the failure against your decoder to get the exact failing path, and fix the decoder or the API contract. Adding more Decode.oneOf branches before you know the failing path usually hides the mismatch.
Recognizable condition and quick triage
The path in an Elm decode error is the route to the value that failed, such as json.items[2].id or json.user.name. Use the table to choose the first check.
| Symptom | Likely cause | First check |
|---|---|---|
Error names a path like json.items[2].id | Schema drift: renamed field, changed type, or missing field | Compare the raw payload at that path with the decoder |
| UI shows an empty list with no visible error | Err swallowed by onError, Result.withDefault, or a catch-all case | Search the update function for places that discard decode errors |
| Decode fails only when an optional field is absent | Decode.field used without Decode.nullable or Decode.maybe | List every field that the API may omit |
| One malformed list element kills the whole list | A single list decoder aborts on the first bad item | Check whether the item decoder needs per-item isolation |
Ordered checks
- Separate transport failure from decode failure. In the update function, log or display the
Http.Errorbranch separately from the decode branch. ABadStatusor network error means the decoder is not the problem yet. No special permissions are needed; this is ordinary application code. - Capture the raw payload. Change the request to
Http.expectJsonwithDecode.value, then encode it back withJson.Encode.encode 2and send it to a port or a temporary test. This gives you the exact bytes the decoder saw, including nulls and numeric strings. AvoidDebug.logfor this:elm make --optimizeforbidsDebugusage in production builds. - Reproduce against your decoder. Run
elm replfrom the project root (no elevated permissions required) and evaluateDecode.decodeString yourDecoder rawJson. TheErrstring names the failing path. If the payload is large, paste it into a test module instead so the reproduction is repeatable. - Read the path literally.
json.items[2].idmeans the third element of theitemsarray has anidthat did not matchDecode.int. Compare that exact value in the raw payload before changing any decoder. - Check whether the error is being discarded. If step 3 succeeds in the REPL but the app still shows empty data, the decoder is fine and the update function is dropping the
Err. Fix the error path before touching the decoder.
Fixes tied to each finding
Schema drift: renamed, missing, or retyped fields
Align the decoder with the payload you captured, not with the API documentation you remember. If a field was renamed, update the string in Decode.field. If a field became optional, treat it as optional in the decoder. Keep the raw sample as a test fixture so the next rename fails in CI rather than in the browser.
Optional fields decoded as required
Decode.nullable accepts an explicit null; it does not accept a missing key. For a key that may be absent, use Decode.oneOf [ Decode.field "price" (Decode.nullable numberDecoder), Decode.succeed Nothing ]. Prefer that over Decode.maybe, because Decode.maybe converts any failure — including a malformed value — into Nothing, which can mask a real type change.
Numbers arriving as strings
JSON has one number type, but backends often serialize decimals as strings to avoid precision loss. A decoder that expects Decode.float will fail on "9.99". Add a narrow fallback that tries the number first and then parses a string, and fail with a message that names the offending value.
One bad list element aborting the whole list
A Decode.list itemDecoder fails entirely if any element fails. If partial data is acceptable, decode each element into a result-like custom type: try itemDecoder, and on failure produce a placeholder that records the index. Do not silently drop elements unless the product decision is explicit.
Swallowed errors
Replace defaults that hide failures with a model state that carries the error message. The user can then see that data failed to load, and your logs or tests can assert on the message. This is a code change, so keep it in a single commit that can be reverted if the error surface is too noisy during rollout.
A concrete decoder and test
The decoder below accepts a numeric string price, treats null and an absent price as Nothing, and fails with a readable message for anything else.
type alias Item =
{ id : Int
, name : String
, price : Maybe Float
}
itemDecoder : Decoder Item
itemDecoder =
Decode.map3 Item
(Decode.field "id" Decode.int)
(Decode.field "name" Decode.string)
(Decode.oneOf
[ Decode.field "price" (Decode.nullable numberDecoder)
, Decode.succeed Nothing
]
)
numberDecoder : Decoder Float
numberDecoder =
Decode.oneOf
[ Decode.float
, Decode.string
|> Decode.andThen
(\s ->
case String.toFloat s of
Just n ->
Decode.succeed n
Nothing ->
Decode.fail ("Expected a number or numeric string, got " ++ s)
)
]
A test using elm-explorations/test pins the behavior. Add it under test-dependencies in elm.json and run elm-test from the project root.
rawJson : String
rawJson =
"""{"id":1,"name":"widget","price":"9.99"}"""
decoderTest : Test
decoderTest =
test "item decoder accepts a numeric string price" <|
\_ ->
Decode.decodeString itemDecoder rawJson
|> Expect.equal (Ok { id = 1, name = "widget", price = Just 9.99 })
For broader coverage, add a fuzz test that generates representative payload variants and checks that decoding either succeeds or returns an Err with a path. The fuzzer does not need to mirror the backend exactly; it needs to cover the shapes you have seen in captured responses.
Verification and limitations
Verify the fix by re-running the captured sample through Decode.decodeString and confirming the result is Ok with the expected record. Then confirm the app no longer shows empty data for that response. If the sample still fails, the error path tells you which field to inspect next.
- Decoder error wording has changed across Elm 0.19.x releases; older blog posts may quote messages that no longer match. Trust the output from your installed compiler.
Decode.maybecan hide malformed values, so use it only when any failure should genuinely meanNothing.Debug.logis not available in optimized builds; use ports or tests for payload capture.- This guide assumes Elm 0.19.1 and the standard
elm/jsonpackage. Other versions may differ.
When to escalate
Escalate to the API owner when the payload shape is undocumented, changes without notice, or differs between environments. Growing decoder complexity is the wrong fix for an unstable contract. Ask for a machine-readable contract such as OpenAPI or JSON Schema, or introduce a boundary validation layer that records and rejects unexpected shapes before they reach feature code. If several clients decode the same endpoint, the contract discussion belongs with the service owner rather than in each client's decoder.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.