This packages helps you writing JSON decoders in a Continuation-passing style. This enables you use named bindings for field names which is very useful when decoding JSON objects to Elm records or custom types.
Let's say you have a Person
record in Elm with the following requirements:
type alias Person =
{ id : Int -- Field is mandatory, decoder should fail if field is missing in the JSON object
, name : String -- Field is mandatory
, maybeWeight : Maybe Int -- Field is attempt in the JSON object
, likes : Int -- Should default to 0 if JSON field is missing or null
, hardcoded : String -- Should be hardcoded to "Hardcoded Value" for now
}
The approach suggested by the core JSON library is to use the Json.Decode.mapN
family of decoders to build
a record.
import Json.Decode as Decode exposing (Decoder)
person : Decoder Person
person =
Decode.map5 Person
(Decode.field "id" Decode.int)
(Decode.field "name" Decode.string)
(Decode.maybe <| Decode.field "weight" Decode.int)
(Decode.field "likes" Decode.int
|> Decode.maybe
|> Decode.map (Maybe.withDefault 0)
)
(Decode.succeed "Hardcoded Value")
Using this package you can write the same decoder like this:
import Json.Decode as Decode exposing (Decoder)
import Json.Decode.Field as Field
person : Decoder Person
person =
Field.require "name" Decode.string <| \name ->
Field.require "id" Decode.int <| \id ->
Field.attempt "weight" Decode.int <| \maybeWeight ->
Field.attempt "likes" Decode.int <| \maybeLikes ->
Decode.succeed
{ name = name
, id = id
, maybeWeight = maybeWeight
, likes = Maybe.withDefault 0 maybeLikes
, hardcoded = "Hardcoded Value"
}
The main advantages over using mapN
are:
- Record field order does not matter. Named bindings are used instead of order. You can change the order of the fields in the type declaration (
type alias Person ...
) without breaking the decoder. - Easier to see how the record is connected to the JSON object. Especially when there are many fields. Sometimes the JSON fields have different names than your Elm record.
- Easier to add fields down the line.
- If all fields of the record are of the same type you won't get any compiler error with the
mapN
approach if you mess up the order. Since named binding is used here it makes it much easier to get things right. - Sometimes fields needs futher validation / processing. See below examples.
- If you have more than 8 fields in your object you can't use the
Json.Decode.mapN
approach since map8 is the largest map function.
In this example the JSON object contains both firstname
and lastname
, but the Elm record only has name
.
JSON
{
"firstname": "John",
"lastname": "Doe",
"age": 42
}
Elm
type alias Person =
{ name : String
, age : Int
}
person : Decoder Person
person =
Field.require "firstname" Decode.string <| \firstname ->
Field.require "lastname" Decode.string <| \lastname ->
Field.require "age" Decode.int <| \age ->
Decode.succeed
{ name = firstname ++ " " ++ lastname
, age = age
}
Using requireAt
or attemptAt
you can reach down into nested objects. This is a
common use case when decoding graphQL responses.
JSON
{
"id": 321,
"title": "About JSON decoders",
"author": {
"id": 123,
"name": "John Doe",
},
"content": "..."
}
Elm
type alias BlogPost =
{ title : String
, author : String
, content : String
}
blogpost : Decoder BlogPost
blogpost =
Field.require "title" Decode.string <| \title ->
Field.requireAt ["author", "name"] Decode.string <| \authorName ->
Field.require "content" Decode.string <| \content ->
Decode.succeed
{ title = title
, author = authorName
, content = content
}
Here, the decoder should fail if the person is below 18.
JSON
{
"name": "John Doe",
"age": 42
}
Elm
type alias Person =
{ name : String
, age : Int
}
person : Decoder Person
person =
Field.require "name" Decode.string <| \name ->
Field.require "age" Decode.int <| \age ->
if age < 18 then
Decode.fail "You must be an adult"
else
Decode.succeed
{ name = name
, age = age
}
You can also use this package to build decoders for custom types.
JSON
{
"name": "John Doe",
"id": 42
}
Elm
type User
= Anonymous
| Registered Int String
user : Decoder User
user =
Field.attempt "id" Decode.int <| \maybeID ->
Field.attempt "name" Decode.string <| \maybeName ->
case (maybeID, maybeName) of
(Just id, Just name) ->
Registered id name
|> Decode.succeed
_ ->
Decode.succeed Anonymous
The following documentation assumes you are familiar with the following functions:
- Json.Decode.field
- Json.Decode.map
- Json.Decode.andThen
- Function application operator <|
You can read more about those here.
Consider this simple example:
import Json.Decode as Decode exposing (Decoder)
type alias User =
{ id : Int
, name : String
}
user : Decoder User
user =
Decode.map2 User
(Decode.field "id" Decode.int)
(Decode.field "name" Decode.string)
Here, map2
from elm/json is used to decode a JSON object to a record.
The record constructor function is used (User : Int -> String -> User
) to build the record.
This means that the order fields are written in the type declaration matters. If you
change the order of fields id
and name
in yor record, you have to change the order of the two
(Decode.field ...)
rows to match the order of the record.
To use named bindings instead you can use Json.Decode.andThen
write a decoder like this:
user : Decoder User
user =
Decode.field "id" Decode.int
|> Decode.andThen
(\id ->
Decode.field "name" Decode.string
|> Decode.andThen
(\name ->
Decode.succeed
{ id = id
, name = name
}
)
)
Now this looks ridicolus, but one thing is interesting: The record is constructed using named variables (in the innermost function).
The fields are decoded one at the time and then the decoded value is bound to a
contiunation function using andThen
. The innermost function will have access to
all the named argument variables from the outer scopes.
The above code can be improved by using the helper function require
. This is
the same decoder expressed in a cleaner way:
module Json.Decode.Field exposing (require)
require : String -> Decoder a -> (a -> Decoder b) -> Decoder b
require fieldName valueDecoder continuation =
Decode.field fieldName valueDecoder
|> Decode.andThen continuation
-- In User.elm
module User exposing (user)
import Json.Decode.Field as Field
user : Decoder User
user =
Field.require "id" Decode.int
(\id ->
Field.require "name" Decode.string
(\name ->
Decode.succeed
{ id = id
, name = name
}
)
)
Now we got rid of some andThen
noise.
Now let's format the code in a more readable way.
user : Decoder User
user =
Field.require "id" Decode.int (\id ->
Field.require "name" Decode.string (\name ->
Decode.succeed
{ id = id
, name = name
}
))
You can also get rid of the parenthesis by using the backwards
function application operator (<|
).
user : Decoder User
user =
Field.require "id" Decode.int <| \id ->
Field.require "name" Decode.string <| \name ->
Decode.succeed
{ id = id
, name = name
}
This reads quite nice. It's like two paragraphs.
- In the first paragraph you extract everything you need from the JSON object and bind each field to a variable. Keeping the field decoder and the variable on the same row makes it easy to read.
- Then in the second paragraph you build the Elm type using all collected values.
It kind of maps to natural language:
require
aField
called"id"
andDecode
anint
, bind the result toid
require
aField
called"name"
andDecode
astring
, bind the result toname
The
Decode
willsucceed
with{name = name, email = email}