So you’ve learned some basic Haskell and you’re feeling really good about yourself. You sit down the write some code and you’re presented with a deeply nested JSON structure:
{
"foo": "Hello",
"bar": 1,
"baz": "More stuff",
"people": [
{
"name": "Drew",
"hobbies": [
{ "name": "bridge" },
{ "name": "haskell" }
]
},
{
"name": "Jane",
"hobbies": [
{ "name": "chess" },
{ "name": "ocaml" }
]
}
]
}
Your goal is to simply find the name of Drew’s first hobby. LET’S WRITE SOME TYPES!
The Hard Way
First, a sprinkling of language extensions, because Haskell.
{-# LANGUAGE DeriveAnyClass #-}
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE QuasiQuotes #-}
module Heavy
( main,
)
where
Next, we’ll import Aeson
, Text
, and a few other helpers.
import Data.Aeson (FromJSON (..), decode, withObject, (.:))
import Data.ByteString.Lazy (ByteString)
import Data.Foldable (find)
import Data.Text (Text)
import GHC.Generics (Generic)
import Text.RawString.QQ (r)
Next our JSON
. We’re using a quasi-quoter to make multi-line strings a bit
easier, and we’re using overloaded strings to easily create a ByteString
literal.
rawJson :: ByteString
rawJson =
[r|
{
"foo": "Hello",
"bar": 1,
"baz": "More stuff",
"people": [
{
"name": "Drew",
"hobbies": [
{ "name": "bridge" },
{ "name": "haskell" }
]
},
{
"name": "Jane",
"hobbies": [
{ "name": "chess" },
{ "name": "ocaml" }
]
}
]
}
|]
What next? The “standard” approach is that we define custom types to mirror the
structure of our JSON
and then attempt to decode the JSON
into those types.
We can leverage GHC Generics to avoid a lot of boilerplate – except whoops,
“name” is used for several keys and Haskell records are a bit unwieldy, so we’ll
have to make a custom parser for hobbies.
newtype Hobby = Hobby
{ hobbyName :: Text
}
deriving (Eq, Show)
instance FromJSON Hobby where
parseJSON = withObject "Hobby" $ \o ->
Hobby <$> o .: "name"
data Person = Person
{ name :: Text,
hobbies :: [Hobby]
}
deriving (Eq, Show, Generic, FromJSON)
newtype Stuff = Stuff
{ people :: [Person]
}
deriving (Eq, Show, Generic, FromJSON)
With that all out of the way, we can finally extract that name we care about and print the result.
findHobby :: Maybe Text
findHobby = do
val <- decode rawJson
drew <- find ((== "Drew") . name) $ people val
case hobbies drew of
[] -> Nothing
hobby : _ -> pure $ hobbyName hobby
main :: IO ()
main = print findHobby
Let’s run it.
$ stack run
Just "bridge"
This works! It’s quite explicit and we’re making strong statements about the
structure of our JSON
. That’s great!
Except we literally throw away all of these beautiful types immediately, because we only care about that one little string value. If only there was an easier way.
The Easy Way
This is the fun part of the post where I recommend using lenses within a section
titled “The Easy Way”. But honestly, I feel that quickly extracting a value from
JSON
is one of the best reasons to start exploring lenses as a user. And it’s
really easy once if that’s all you’re trying to do! We’ll make use of the
lens-aeson
library, which is truly a joy.
First, we’ll have a lighter sprinkling of language extensions.
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE QuasiQuotes #-}
module Light
( main,
)
where
Next, our imports and raw JSON
.
import Control.Lens (filtered, preview, view)
import Data.Aeson (Value, decode)
import Data.Aeson.Lens (key, nth, values, _String)
import Data.ByteString.Lazy (ByteString)
import Data.Text (Text)
import Text.RawString.QQ (r)
rawJson :: ByteString
rawJson =
[r|
{
"foo": "Hello",
"bar": 1,
"baz": "More stuff",
"people": [
{
"name": "Drew",
"hobbies": [
{ "name": "bridge" },
{ "name": "haskell" }
]
},
{
"name": "Jane",
"hobbies": [
{ "name": "chess" },
{ "name": "ocaml" }
]
}
]
}
|]
Note that we’re only pulling in a few helpers from lens-aeson
, namely:
key
to access object keysnth
to access elements in an arrayvalues
to consider all values in an array_String
to extract the final string value
We’re also making use of two functions from lens
itself:
view
to actually view the result, returningmempty
if the path is badpreview
to actually view the result, returningNothing
if the path is badfiltered
to only consider aPerson
with the name “Drew”
Let’s ignore everything about lenses other than the fact that you compose them
with .
and that they compose left-to-right, the opposite of normal Haskell
function composition. Given this, we can view lenses as a kind of “xpath” for
digging data out of JSON
. To the code!
isDrew :: Value -> Bool
isDrew v = view (key "name" . _String) v == "Drew"
findHobby :: Maybe Text
findHobby = do
val <- decode rawJson :: Maybe Value
let path =
key "people"
. values
. filtered isDrew
. key "hobbies"
. nth 0
. key "name"
. _String
preview path val
main :: IO ()
main = print findHobby
This time, we decode our JSON
directly into a Value
type, which will only
fail if the JSON
is invalid. We then use our lenses to dig into the Value
,
extracting Drew’s first hobby. Note that because we’re operating on Value
s,
our isDrew
function needs the type of Value -> Bool
. We use a lens lookup
internally to see if the person we’re considering is, in fact, Drew.
But what’s the type of path? Who cares! I refuse to tell you.
Let’s run it.
$ stack run
Just "bridge"
Try Lenses
Next time you just need to get a value out of a deeply-nested JSON
object,
try lens-aeson
. I think you’ll be glad you did.