Prevent Impossible States with JSON Decoders in Elm

Julio Feijo
4 min readOct 18, 2018

--

When we receive JSON values, there is often a risk of introducing impossible states into our application. Let’s say that we would like to show the status of a running process; we ask the server for this status, and get a JSON response back. If the process has finished, we also expect to receive the end date.

// pending (without finishedAt field)
myCoolProcess: {
"status": "pending"
}
// running (without finishedAt field)
myCoolProcess: {
"status": "running"
}
// success (with finishedAt field)
myCoolProcess: {
"status": "success",
"finishedAt: "2018-08-25"
}
// fail (with finishedAt field)
myCoolProcess: {
"status": "fail",
"finishedAt: "2018-08-25"
}

Here’s a naive way to decode these JSON values into Elm:

import Json.Decode as Decode exposing (Decoder)
import Json.Decode.Pipeline exposing (decode, optional, required)
type alias MyCoolProcess =
{ status : String
, finishedAt : Maybe String
}
myCoolProcessDecoder : Decoder MyCoolProcess
myCoolProcessDecoder =
decode MyCoolProcess
|> required "status" Decode.string
|> optional "finishedAt" (Decode.nullable Decode.string) Nothing

This does the work, but we can get all sorts of impossible states, for instance:

myCoolProcess = { status = "random string", finishedAt: Nothing }
or
myCoolProcess = { status = "running", finishedAt: Just "2018-08-25" }

To improve this, let’s forbid any string that is not one of the expected values. We can accomplish this by using a custom type(union type) instead of a string in our record. However, the decoder will not be as simple as it was before. You can see the result below:

import Json.Decode as Decode exposing (Decoder)
import Json.Decode.Pipeline exposing (optional, required)
type alias MyCoolProcess =
{ status : Status
, finishedAt : Maybe String
}
type Status
= Pending
| Running
| Success
| Fail
myCoolProcessDecoder : Decoder MyCoolProcess
myCoolProcessDecoder =
Decode.succeed MyCoolProcess
|> required "status" statusDecoder
|> optional "finishedAt" Decode.string
statusDecoder : Decoder Status
statusDecoder =
Decode.field "status" Decode.string
|> Decode.andThen mapStrToStatus
mapStrToStatus : String -> Decoder Status
mapStrToStatus status =
case status of
"pending" ->
Decode.succeed Pending
"running" ->
Decode.succeed Running
"success" ->
Decode.succeed Success
"fail" ->
Decode.succeed Fail
_ ->
Decode.fail <|
"Trying to decode my cool process status, but status \""
++ status
++ "\" is not supported."

We cannot simply useDecode.string anymore for our status field. We have to map the decoded string to a valid state. What we are telling to the decoder in plain English is: “decode the status field using Decode.string and after you finish, call mapStrToStatus with the value you decoded”. mapStrToStatus receives the decoded string and, using a case of expression to match the expected strings, returns a decoder that produces the corresponding state value (or fails if the string is not recognised).

Now we can’t have invalid states, like: myCoolProcess = { status = "random string" }. Congratulations! We are halfway there. We still decode a few impossible states, though, such as a process that is still running but has a “finished at” date:

myCoolProcess = { status = Running, finishedAt: Just "2018-08-25" }

Let’s fix that by transforming our whole MyCoolProcess record into a custom type, and changing our decoder to support it. The result code is the code below:

import Json.Decode as Decode exposing (Decoder)type MyCoolProcess
= Pending
| Running
| Success FinishedAt
| Fail FinishedAt
type alias FinishedAt = StringmyCoolProcessDecoder : Decoder MyCoolProcess
myCoolProcessDecoder =
Decode.field "status" Decode.string
|> Decode.andThen mapStrToStatus
mapStrToStatus : String -> Decoder MyCoolProcess
mapStrToStatus status =
case status of
"pending" ->
Decode.succeed Pending
"running" ->
Decode.succeed Running
"success" ->
finishedAtDecoder Success
"fail" ->
finishedAtDecoder Fail
_ ->
Decode.fail <|
"Trying to decode last auto sync status, but status \""
++ status
++ "\" is not supported."
finishedAtDecoder : (FinishedAt -> MyCoolProcess) -> Decoder MyCoolProcess
finishedAtDecoder toMyCoolProcess =
Decode.field "finishedAt" Decode.string
|> Decode.map toMyCoolProcess

The first step was replacing MyCoolProcess to be a custom type instead of a record. On the decoder level, we will have to decode the finishedAt field together with the status field. The major change happens in mapStrToStatus function. What it does in plain English is: “Map the status string to a valid status value, and if you find a Success or Fail status, you have also to decode the finishedAt field”.

We made it! Now our Elm application does not allow any impossible states by default (unless you count the fact that we accept any string as a finishedAt date — decoding dates is a whole other story!). Notice, that it requires a greater effort to accomplish, so I suggest to use it only when you have a good understanding of your data. If you are in a prototyping phase or not sure yet how your API will respond, you should start with the naive way and refactor once you have greater certainty.

One thing that is missing is how to handle the error when our decoder fails, but this is a topic for another post. See you next time!

--

--