Handling Conditional API Shapes in Elm with Json.Decode.andThen
Decode untrusted API payloads safely in Elm by parsing at the boundary and turning invalid JSON into Result messages instead of runtime crashes.
08 Jul 2026, 01:30 UTC

Decode at the edge, not inside your domain
Your Elm frontend receives a user object from an API. The object always has id and name, but the role field changes the shape: admins include a permissions array, while viewers do not. If you decode every field independently or reach for Json.Decode.value to avoid thinking about the shape, you either write fragile code or give up Elm’s compile-time guarantees.
The practical takeaway is to decode at the edge of your application. Use Json.Decode to transform raw JSON into Elm records, and use andThen when one field determines how to decode another. When decoding fails, Elm returns a Result value—either Ok with your data or Err with a description—so a bad payload becomes an ordinary message you can handle, not a runtime exception.
What a boundary means in Elm
The boundary is where untrusted data enters your app, usually an HTTP response body. Your domain model should never contain JSON-shaped data. A Decoder a is a description of how to read a value of type a from JSON. Result is Elm’s built-in type for recoverable failures: it is either Ok value or Err error.
Decode a conditional user shape with andThen
This decoder first reads role, then chooses how to decode permissions based on that value. D.succeed role lifts an already-known value into a decoder so map4 can pass it to the record constructor.
module User exposing (User(..), decodeUser)
import Json.Decode as D
type Role
= Admin
| Viewer
type alias User =
{ id : Int
, name : String
, role : Role
, permissions : List String
}
decodeRole : D.Decoder Role
decodeRole =
D.field "role" D.string
|> D.andThen (\roleString ->
case roleString of
"admin" ->
D.succeed Admin
"viewer" ->
D.succeed Viewer
_ ->
D.fail ("Unexpected role: " ++ roleString)
)
decodePermissions : Role -> D.Decoder (List String)
decodePermissions role =
case role of
Admin ->
D.field "permissions" (D.list D.string)
Viewer ->
D.succeed []
decodeUser : D.Decoder User
decodeUser =
D.andThen (\role ->
D.map4 User
(D.field "id" D.int)
(D.field "name" D.string)
(D.succeed role)
(decodePermissions role)
)
decodeRole
Run the decoder on a raw payload like this:
case D.decodeString decodeUser payload of
Ok user ->
-- user is a fully typed Elm record; safe to use in views and updates
Just user
Err error ->
-- error is a developer-facing decoder message
Nothing
Translate decoder errors before showing them
Decoder failure messages are technical and developer-facing. A missing field or a type mismatch produces a message that helps you fix the decoder, not one that helps a user understand what went wrong. Map D.Error to your own user-facing strings before rendering anything in the UI.
Verify before you trust it
Run these commands in your Elm project root. They require no special permissions.
elm install elm/json— adds the package toelm.json. Requires network access. Risk: the command can fail offline or select a version incompatible with your Elm version. Confirmelm/jsonappears inelm.jsondependencies before importingJson.Decode.elm make src/Main.elm— compiles the project. Expected: a successful build or a module-not-found error if the package is missing.elm repl, then:t decodeUser— inspects the inferred type. Expected:Decoder User.- Call
D.decodeString decodeUser \"{}\"with a malformed payload. Expected: the result isErr, not a runtime crash.
Limitations
- Decoder combinators are verbose compared to dynamic languages, so complex or nested payloads require more upfront code.
andThencan make decoders harder to read when many fields depend on one another. Consider normalizing the JSON on the server or pre-processing it before decoding.Json.Decode.valueshould be reserved for truly unknown schemas. Leaking it into domain logic weakens Elm’s no-runtime-exception guarantee.
Closing
Start at the boundary. Decode the payload into a strict Elm record, handle the Err case as a normal message, and keep the rest of your application free of JSON-shaped data. If your server sends conditional shapes, andThen keeps the decoder honest without sacrificing type safety.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.