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 Maybe
s 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.
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 wereMaybe
s?
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 wereDecoder
s?
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:
- Replace uses of the type
Maybe
withDecoder
- Replace uses of the module
Maybe
withDecode
- Re-implement the “primitive” functions in terms of the
Decode
module’s primitives (int
/float
/string
/bool
) and possiblyfield
. - Replace uses of
Nothing
withDecode.fail
- Any remaining uses of
Just
can probably beDecode.succeed
- 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.