You’ve learned the basics of decoding JSON in Elm and are comfortable converting JSON into records. However, real life has a tendency to give you tricky JSON to work with that doesn’t fit neatly with this approach.
Here are five scenarios I commonly run into and how to decode them.
In all these examples, you can assume the decode library has been imported like:
import Json.Decode as JD exposing (Decoder)
1 - Decoding union types
Oftentimes you will want to express a limited set of values as a union type:
type Direction = North | South | East | West
JSON doesn’t support values like this and will probably send these values down as strings or integers. We’ll need to decode this in two steps: first decode the string, and then turn the string into a direction. Because we might get an invalid string value, we need to handle errors too.
direction : Decoder Direction
direction =
JD.string |> JD.andThen directionFromString
directionFromString : String -> Decoder Direction
directionFromString string =
case string of
"north" -> JD.succeed North
"south" -> JD.succeed South
"east" -> JD.succeed East
"west" -> JD.succeed West
_ -> JD.fail ("Invalid direction: " ++ string)
2 - Separate decoding and parsing union types
If you are parsing union types from strings elsewhere in your code, you may want
to separate the parsing from the decoding. Write a regular parsing function that
returns a Result
and write a function that can convert that Result
into a
Decoder
.
parseDirection : String -> Result String Direction
parseDirection string =
case string of
"north" -> Ok North
"south" -> Ok South
"east" -> Ok East
"west" -> Ok West
_ -> Err ("Invalid direction: " ++ string)
This is almost exactly the same as our decoder from the previous section except
that it returns Result
directly without having to go through the extra step of
running the decoder.
It would be nice not to duplicate the case statement and instead have some way
of implementing our decoder in terms of parseDirection
. We can do that if we
have a way to turn Result
s into Decoder
s.
fromResult : Result String a -> Decoder a
fromResult result =
case result of
Ok a -> JD.succeed a
Err errorMessage -> JD.fail errorMessage
This function is so useful that it also exists in the third-party package
json-extra as the fromResult
function.
Finally we can put it all together with:
direction : Decoder Direction
direction =
JD.string |> JD.andThen (fromResult << parseDirection)
3 - Additional parsing
Sometimes, you need to parse out a type that JSON doesn’t support such as a date. You can use the same parse and decode approach we used for the union types:
date : Decoder Date
date =
JD.string |> JD.andThen (fromResult << Date.fromString)
It is common for backends to mistakenly encode numbers as JSON strings rather than numbers. To read them as numbers in Elm, we’ll need to use this parse and decode approach:
stringInt : Decoder Int
stringInt =
JD.string |> JD.andThen (fromResult << String.toInt)
See both of these in action in Ellie
4 - Conditional decoding based on the shape of the JSON
Sometimes your JSON can be in multiple shapes and you’d like to decode it differently based on the shape. Here we have a payload that may or may not have an email depending on whether the user is signed in.
{ "email": "user@example.com",
"otherField": "foo"
}
versus
{ "otherField": "foo"
}
We might model that on the Elm side with:
type User = Guest | SignedIn String
First create decoders for each individual kind of user:
guestDecoder : Decoder User
guestDecoder =
JD.succeed Guest
signedInDecoder : Decoder User
signedInDecoder =
JD.map SignedIn (JD.field "email" JD.string)
Json.Decode.oneOf
allows you to specify a list of possible decoders. It will
go through the list in order and use the first one that can successfully decode
the JSON. Finally we can write a userDecoder
that looks like:
userDecoder : Decoder User
userDecoder =
JD.oneOf [signedInDecoder, guestDecoder]
There are some gotchas around this technique. Order is really important and your different JSONs must have different shapes. For example:
JD.oneOf [guestDecoder, signedInDecoder]
will always decode a guest. This is because guestDecoder
cannot fail so
oneOf
will never attempt other decoders after it.
If your JSON always has the same shape but depending on some of the values you
want to decode differently, you’ll need to use a different approach based on
Json.Decode.andThen
.
5 - Conditional decoding based on a field
Say you have two kinds of users but the JSON payload have the same shape:
{ "role": "admin"
, "email": "admin@example.com"
}
versus
{ "role": "regular"
, "email": "regular@example.com"
}
We could model this in Elm as:
type User = Admin String | RegularUser String
Again, we start by writing decoders for each case:
adminDecoder : Decoder User
adminDecoder =
JD.map Admin (JD.field "email" JD.string)
regularUserDecoder : Decoder User
regularUserDecoder =
JD.map RegularUser (JD.field "email" JD.string)
The JSON for both of these would have the same shape so we can’t use oneOf
.
Instead, we’ll have to decode based on the values of some of the fields in the
JSON. In this particular case, the backend team have helpfully added a role
field to the JSON that will be either "regular"
or "admin"
:
userFromType : String -> Decoder User
userFromType string =
case string of
"regular" -> regularUserDecoder
"admin" -> adminDecoder
_ -> JD.fail ("Invalid user type: " ++ string)
userDecoder : Decoder User
userDecoder =
JD.field "role" JD.string
|> JD.andThen userFromType
General tips
When you need a different decoder depending on context, start with the smallest pieces by writing a small decoder for each case.
Once you have decoders for each case, look to see how you will know which decoder to pick. Write a function that picks the right decoder based on your decision.
Finally, put it all together using functions like JD.andThen
.