5 Common JSON Decoders

Joël Quenneville

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)

See it in action in Ellie.

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 Results into Decoders.

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)

See it in action in Ellie

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.

See it in action in Ellie

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

See it in action in Ellie.

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.