Getting Unstuck with Elm JSON Decoders

Joël Quenneville

Elm’s JSON decoders can be tricky to wrap your head around. You’re trying to decode a value and aren’t coming up with anything useful. How do you get unstuck?

One solution I use is to try to simulate the problem using Maybes instead of decoders. Then once I have a solution to the Maybe problem, I can use it to help guide me towards the equivalent decoder solution.

Diagram: JSON to Maybe back to JSON

Decoding a record

Say we have a User record that looks like this:

type alias User = { name : String, age : Int }

We’re struggling to figure out how to decode this. Instead of banging our heads against the wall, let’s try to solve an easier problem:

How could we construct a Maybe User if all the component parts were Maybes?

Start by making concrete functions for those component parts, the maybe name and maybe age:

maybeName : Maybe String
maybeName =
  Just "John Doe"

maybeAge : Maybe Int
maybeAge =
  Just 42

Great! Now how can you combine those to get a Maybe User? You might start with some nested case statements. Eventually you realize those nested case statements are equivalent to Maybe.map2. You end up with the following solution:

maybeUser : Maybe User
maybeUser =
  Maybe.map2 User maybeName maybeAge

OK, so we know how to assemble a Maybe User out of a Maybe name and age. How does that help us decode JSON? Well we probably already know how to get some primitives out of JSON such as the name and the age. Let’s start there:

nameDecoder : Decoder String
nameDecoder =
  Decode.field "name" Decode.string

ageDecoder : Decoder Int
ageDecoder =
  Decode.field "age" Decode.int

The real question then becomes:

How could we construct a Decoder User if all the component parts were Decoders?

Sound familiar? We can translate our Maybe solution pretty much directly into a Decoder solution:

userDecoder : Decoder User
userDecoder =
  Decode.map2 User nameDecoder ageDecoder

More Complex example (custom types)

OK, that was a roundabout way to figure out how to decode a record. Every decoding tutorial online shows how to decode these directly without doing any of this Maybe nonsense. How do we deal with more complex decoding problems? This technique still holds up.

We have a custom type that represents the users of our app:

type User = Admin String | RegularUser String

and we’re getting JSON that looks like:

{ "role": "admin"
, "email": "admin@example.com"
}

How can we decode this? Let’s translate the problem into Maybe. Given some optional primitives like:

maybeRole : Maybe String
maybeRole =
  Just "admin"

maybeEmail : Maybe String
maybeEmail =
  Just "admin@example.com"

How can we combine them to get a Maybe User?

As a first step we need to construct either an admin or a regular user from the email. We could use case statements to unwrap the email but it turns out that’s equivalent to a Maybe.map.

maybeAdmin : Maybe User
maybeAdmin =
  Maybe.map Admin maybeEmail

maybeRegularUser : Maybe User
maybeRegularUser =
  Maybe.map RegularUser maybeEmail

We don’t want both of these. Instead we want to choose one based on a role. Let’s write a choosing function:

chooseFromRole : String -> Maybe User
chooseFromRole role =
  case role of
    "admin"   -> maybeAdmin
    "regular" -> maybeRegularUser
    _         -> Nothing

Finally, we want to unwrap the Maybe role and call our choosing function based on it. Again we can write some case statements but they would be equivalent to the built-in Maybe.andThen helper.

maybeUser : Maybe User
maybeUser =
  maybeRole
    |> Maybe.andThen chooseFromRole

All together, this looks like:

maybeUser : Maybe User
maybeUser =
  maybeRole
    |> Maybe.andThen chooseFromRole


-- CHOOSE AN IMPLEMENTATION

chooseFromRole : String -> Maybe User
chooseFromRole role =
  case role of
    "admin"   -> maybeAdmin
    "regular" -> maybeRegularUser
    _         -> Nothing


-- BUILD ADMIN/REGULAR

maybeAdmin : Maybe User
maybeAdmin =
  Maybe.map Admin maybeEmail

maybeRegularUser : Maybe User
maybeRegularUser =
  Maybe.map RegularUser maybeEmail


-- PRIMITIVES
-- THIS IS WHAT WE STARTED WITH

maybeRole : Maybe String
maybeRole =
  Just "admin"

maybeEmail : Maybe String
maybeEmail =
  "admin@example.com"

Use the following steps to convert this solution to a Json.Decode solution:

  1. Replace uses of the type Maybe with Decoder
  2. Replace uses of the module Maybe with Decode
  3. Re-implement the “primitive” functions in terms of the Decode module’s primitives (int/float/string/bool) and possibly field.
  4. Replace uses of Nothing with Decode.fail
  5. Any remaining uses of Just can probably be Decode.succeed
  6. Tweak the names of your functions 😎

The end result is uncannily similar to our Maybe solution:

userDecoder : Decoder User
userDecoder =
  roleDecoder
    |> Decode.andThen chooseFromRole


-- CHOOSE AN IMPLEMENTATION

chooseFromRole : String -> Decoder User
chooseFromRole role =
  case role of
    "admin"   -> adminDecoder
    "regular" -> regularUserDecoder
    _         -> Decode.fail ("Invalid user type: " ++ string)


-- BUILD ADMIN/REGULAR

adminDecoder : Decoder User
adminDecoder =
  Decode.map Admin emailDecoder

regularUserDecoder : Decoder User
regularUserDecoder =
  Decode.map RegularUser emailDecoder


-- PRIMITIVES

roleDecoder : Decoder String
roleDecoder =
  Decode.field "role" Decode.string

emailDecoder : Decoder String
emailDecoder =
  Decode.field "email" Decode.string

Why does this work?

The Maybe type is easier to grasp. It feels more concrete than Decoder. It also exposes it’s constructors allowing us pattern-match and case on Just and Nothing. We can take Maybe apart and put it back together, building an intuition for what it represents and how it works. Most Elm devs will find it easier to solve a problem in terms of Maybe rather than Json.Decode.

Maybe has another important property. Most of its key helper functions like map and andThen behave the same as the equivalent functions on Json.Decode. This means we can take lessons learned while solving the easier Maybe problem and transfer them to solving the harder decoder problem.

Translating hard problems into an easier problem space, solving the easy problem, and then translating the easy solution back to help solve the initial hard problem is a form of reasoning by analogy. This is a powerful debugging and general learning technique that’s well worth including in your toolkit.