To decode JSON objects in Elm, we use the Decode.object*
functions. This gives a problem when decoding objects with more than eight fields, because these decode functions are only defined up to Decode.object8
. For me, decoding large json object is pretty common, so a better solution is needed.
Naive solution
A solution that I came upon on reddit is to use Decode.andThen
and Decode.succeed
to make a decoder.
import Html exposing (text)
import Json.Decode as Decode exposing
(Decoder, (:=), oneOf, maybe
, string, float, andThen)
-- example json returned by the server (as an elm string)
serverResponse = \"\"\"{"tag": "input", "default": "test"}\"\"\"
type alias Response = { tag : String, default : String }
decodeResponse : Decoder Response
decodeResponse =
("tag" := string) `andThen` (\\tag ->
("default" := string) `andThen` (\\default ->
Decode.succeed (Response tag default)))
main =
Decode.decodeString decodeResponse serverResponse
|> toString
|> text
This works, but it’s not great:
- arguments need to be named. this is unwieldy
- many brackets and inline functions
- this looks messy
*Fist on table* There MUST be a better way!
Enter Applicative Functors
The andThen
pattern in the above example seems very regular, and technically it does the job. Maybe it is possible to abstract this pattern (that is, hide it in some function so it’s not visible any more - out of sight, out of mind.)
It turns out that by applying some abstract mathematics, the above example can be simply (simple, not easy) transformed into something much more readable. The trick is to apply Applicative Functors.
Functor
So, what is a functor? Functor is a concept and name from category theory, famous for its cool but vague words. In Elm, a functor is a type that has a map
function defined on it (or this function can be defined.) For instance List.map : (a -> b) -> List a -> List b
or Maybe.map : (a -> b) -> Maybe a -> Maybe b
. Notice that these function signatures have the same shape: (a -> b) -> f a -> f b
(where f is a functor, like List
or Maybe
.)
So, when a function map
with the signature (a -> b) -> f a -> f b
can be defined, the type f is probably a functor.
To be sure, the map
function also has to satisfy the Functor Law. These need to be checked by the programmer. See if you can prove this for Maybe and List.
map identity f == identity f
Applicative
Applicative is a specialisation of a functor. That means that any applicative is also a functor, but the inverse need not be true. Where Functor has the characteristic function map
, applicative has two functions: andMap : f (a -> b) -> f a -> f b
and pure : a -> f a
(where, for lack of a better letter, f stands for Applicative.)
The pure
function puts any type into an Applicative. examples of this are Just : a -> Maybe a
or (\\x -> [x]) : a -> List a
.
For List
the andMap
function can be defined as follows:
andMap : List (a -> b) -> List a -> List b
andMap fs xs = List.concat <| List.map (\\f -> List.map f xs) fs
-- easier version: requires almost no thinking.
andMap' : List (a -> b) -> List a -> List b
andMap' = List.map2 (<|)
Again, there are some laws:
-- identity
pure identity `andMap` v = v
-- composition
pure (<<) `andMap` u `andMap` v `andMap` w
= u `andMap` (v `andMap `w`)
-- homomorphism
pure f `andMap` pure x = pure (f x)
-- interchange
u `andMap` (pure y) = pure ((<|) y) `andMap` u
These laws are a lot more technical. Luckily, in day-to-day Elm they never really pop up, because category theory is not (yet) a leading design pattern. If you like this, go try some haskell; Haskell knowledge can be very useful in Elm (and vice versa.)
The use of this abstract pattern may not be clear initially. When, in practice, do you get a List of functions? And even if you encounter it at some point, defining this function with concat and map is not that difficult. The power lies in the generality. The shortcut version of this function is also quite handy.
Solving problems
So, that is the theoretical part. What is the idea here? Remember that the goal is to decode a certain amount of elements and feed them to a function. The example with andThen
at the top first decodes all the arguments and then applies them. This pattern leads to a lot of extra syntax and requires naming. Instead, the composition characteristic of andMap can be used to start with a decoder of a function, decode and apply one argument, and then recurse for however many arguments are needed.
More concretely, a function is needed of the type Decoder (a -> b) -> Decoder a -> Decoder b
(i.e. andMap for Decoder.) Therefore, Decoder has to be an Applicative Functor. Let’s see how the functions map
, andMap
and pure
can be implemented.
Decoder.map : (a -> b) -> Decoder a -> Decoder b
is already defined in Json.Decode
, so Decode is a functor. The type signature for pure
is the same as for Decode.succeed
. The only challenge is andMap
.
It is not very obvious, but with some type puzzeling:
-- function application
(<|) : (a -> b) -> a -> b
f <| x = f x
-- now substitute (<|) as the first argument to Decode.object2
Decode.object2 : (c -> d -> value)
-> Decoder c
-> Decoder d
-> Decoder value
-- gives
andMap : Decoder (a -> b) -> Decoder a -> Decoder b
andMap = Decode.object2 (<|)
Not obvious. Hints are that this function takes two decoder arguments (for the function and an argument) and that its type signature looks very similar to List.map2 : (a -> b -> value) -> List a -> List b -> List value
.
Great, Decoder is an applicative. Let’s apply it. The example at the start of this post can now be rewritten as follows (I’ve added the type of every line in the decoder as a comment.)
serverResponse = \"\"\"{"tag": "input", "default": "test"}\"\"\"
type alias Response = { tag : String, default : String }
decodeResponse : Decoder Response
decodeResponse =
Decode.succeed Response -- Decoder (String -> (String -> Response))
`andMap` ("tag" := string) -- Decoder (String -> Response)
`andMap` ("default" := string) -- Decoder Response
Now, depending on your stance toward infix operators, you can clean this up further using
(|:) = andMap
decodeResponse : Decoder Response
decodeResponse =
Decode.succeed Response
|: ("tag" := string)
|: ("default" := string)
This is visually pleasing and easilly extendable. The usual argument that infix symbolic operators obfuscate code doesn’t really hold here in my opinion. Its effect (within the context of decoding a JSON object) is easilly inferable from the code.
conclusion
Using a bit of category theory (the branch of mathematics that studies structures like functors and applicatives) it is possible to significantly improve code.