One field too many
Sometimes you can’t find a mapN
with a high enough N
me> Aaaaargh, not again!
wat> I hate the sound of frustration in the morning. What’s wrong?
me> What’s wrong? I’ll tell you what’s wrong! That is what’s wrong:
type alias Person =
{ firstName: String
, lastName: String
, age: Int
, email: String
, phone: String
, hairColor: String
, eyeColor: String
, favoriteFood: String
, favoriteDrink: String
}
wat> That looks like a very fine type alias (if a bit on the wide side). What about it?
me> It used to have two less fields (favoriteFood
and favoriteDrink
are late additions) so when I wanted to decode it from JSON, I could just use Decode.map7
and be done with it:
type alias Person =
{ firstName: String
, lastName: String
, age: Int
, email: String
, phone: String
, hairColor: String
, eyeColor: String
}personDecoder : Decoder Person
personDecoder =
Decode.map7 Person
(field "firstName" string)
(field "lastName" string)
(field "age" int)
(field "email" string)
(field "phone" string)
(field "hairColor" string)
(field "eyeColor" string)
wat> So it was. Now you need a map9
I guess…
me> …which, as it happens, does not exist. Or at least not in Json.Decode
.
wat> Well, some people¹ will tell you that this is a design problem, and that you should be using a nested structure instead, grouping fields who go together and putting them in smaller boxes that you can put in a bigger box, etc.
me> Well, sure, but… isn't there a… functor-y way to do it without having to refactor all my code just to make space for the new fields?…
wat> Are you somehow trying to manipulate me into revealing more forbidden, esoteric knowledge of deep, dark computer science?
me> …yes?
wat> Well, it's… working obviously. Here, take a look at this intriguing new function:
-- (from Json.Decode.Extra)
andMap : Decoder a -> Decoder (a -> b) -> Decoder b
me> Wait a minute… don’t I know this from somewhere?… The name is kinda familiar…
wat> You might find it looks kind of like Json.Decode.map
if you switched the parameters (and squinted quite a bit):
-- (from Json.Decode.Extra)
andMap : Decoder a -> Decoder (a -> b) -> Decoder b-- (from Json.Decode)
map : (a -> b) -> Decoder a -> Decoder b
me> Yeah… but it’s as if we swapped the transformation function with the decoder… and wrapped it in a Decoder
for some reason. Why would we wrap a function inside a Decoder
?
wat> Let's say for now this is not a function. Let's say it's an "empty b
" that only needs an a
to be complete.
me> Hmm… ok. So, it's a constructor of kinds?
wat> Sure. Now let's say that andMap
is like a robotic arm that can pull an a
from a Decoder a
and put that a
inside our "empty b
" to make a complete b
.
me> Got it.
wat> Let’s see it in action with the easiest of examples, a decoder that takes a string field and feed it to a very simple person:
type alias Person =
{ firstName: String }personDecoder : Decoder Person
personDecoder =
andMap
(field "firstName" string) -- : Decoder String
(succeed Person) -- : Decoder (String -> Person)
me> That sounds… uselessly complicated. But yes, you take a string decoder that extracts the string from the firstName
field, andMap
somehow unwraps it and then feeds it to the Person
constructor in its always-succeeding decoder. And I guess when the Decoder String
fails, the Decoder Person
we get is just a failing decoder. I guess I get it.
wat> Now let’s write it in a more pipeline-y way:
personDecoder =
succeed Person
|> andMap (field "firstName" string)
me> Ok, that’s the same thing only we feed the constructor in its always-succeeding decoder to andMap
using (|>)
. So what?
wat> Now let’s add another field to Person
, that also needs decoding:
type alias Person =
{ firstName: String
, lastName: String
}personDecoder : Decoder Person
personDecoder =
succeed Person
|> andMap (field "firstName" string)
|> andMap (field "lastName" string)
wat> No mapN
required 😉
me> Oh. It's like your "robotic arms" are chained together to form some kind of assembly line, putting the elements one by one in our constructor… Brilliant!
wat> Yes! Let’s return to our definition of andMap
shall we?
andMap : Decoder a -> Decoder (a -> b) -> Decoder b
wat> So, we have this Person : String -> String -> Person
constructor that wants two strings to build its Person
value, right? And we have two decoders that can decode the strings. But those decoders may fail. So we first wrap the constructor into a successful Decoder
, and we use andMap
to combine them with the decoding operation:
succeed Person -- this is a Decoder (String -> String -> Person)
|> andMap (field "firstName" string)
-- the result is a Decoder (String -> Person)
-- (the first String is provided by the first decoder)
|> andMap (field "lastName" string)
-- the result is a Decoder Person
-- (now it can decode a whole Person :)
me> Let me try and…
type alias Person =
{ firstName: String
, lastName: String
, age: Int
, email: String
, phone: String
, hairColor: String
, eyeColor: String
, favoriteFood: String
, favoriteDrink: String
}
personDecoder : Decoder Person
personDecoder =
succeed Person
|> andMap (field "firstName" string)
|> andMap (field "lastName" string)
|> andMap (field "age" int)
|> andMap (field "email" string)
|> andMap (field "phone" string)
|> andMap (field "hairColor" string)
|> andMap (field "eyeColor" string)
|> andMap (field "favoriteFood" string)
|> andMap (field "favoriteDrink" string)
me> 😢 this is so beautiful…
wat> And you know what?… Just like map
is the Functor’s super-important function, and andThen
the Monad’s, there is a name for things that you can andMap
over…
me> Later. Let me bask in the feeling.
wat> …and that name is Applicative Functor. We’ll try and generalize soon. Bye.
[1] do not listen to these people ; your big flat objects are fine if you think they're fine ; you should group when it makes sense to you, not to please someone else. Also, those people tend to be the same that find your objects too deeply nested anyways.