Skip to content
AgileLogics
7 min read

JSON with aeson

Prerequisites: Type classes

aeson is Haskell's standard JSON library. You describe how your types map to JSON with the ToJSON and FromJSON classes — usually by deriving them — and aeson handles encoding, decoding, and validation.

Deriving instances with Generic

{-# LANGUAGE DeriveGeneric, DeriveAnyClass, OverloadedStrings #-}

import Data.Aeson
import GHC.Generics (Generic)
import qualified Data.ByteString.Lazy.Char8 as BL

data User = User
  { name :: String
  , age :: Int
  , email :: Maybe String
  }
  deriving (Show, Generic, ToJSON, FromJSON)

main :: IO ()
main = do
  BL.putStrLn (encode (User "Ada" 36 Nothing))
  BL.putStrLn (encode [User "Alan" 41 (Just "alan@example.com")])
  print (decode "{\"name\":\"Grace\",\"age\":85}" :: Maybe User)
  print (eitherDecode "{\"name\":\"Grace\"}" :: Either String User)
  print (eitherDecode "{\"name\":\"Grace\",\"age\":\"old\"}" :: Either String User)
{"age":36,"email":null,"name":"Ada"}
[{"age":41,"email":"alan@example.com","name":"Alan"}]
Just (User {name = "Grace", age = 85, email = Nothing})
Left "Error in $: parsing Main.User(User) failed, key \"age\" not found"
Left "Error in $.age: parsing Int failed, expected Number, but encountered String"
  • encode produces a lazy ByteString; decode returns a Maybe, and eitherDecode an error message saying what was wrong and where.
  • A Maybe field becomes null when encoding, and may be missing when decoding.
  • The keys come out in alphabetical order, because these derived instances build an aeson Value (whose objects are sorted maps) before encoding it. JSON object order carries no meaning, so this rarely matters.
  • OverloadedStrings lets the JSON string literals be ByteStrings.
  • Decoding is validation: if the JSON doesn't match the type, you get an error instead of a half-filled object.

Custom field names

JSON often uses a different naming style. genericToJSON and genericParseJSON take options:

{-# LANGUAGE DeriveGeneric, OverloadedStrings #-}

import Data.Aeson
import GHC.Generics (Generic)
import qualified Data.ByteString.Lazy.Char8 as BL

data Order = Order {orderId :: Int, orderTotalCents :: Int}
  deriving (Show, Generic)

options :: Options
options = defaultOptions {fieldLabelModifier = camelTo2 '_' . drop 5}

instance ToJSON Order where
  toJSON = genericToJSON options

instance FromJSON Order where
  parseJSON = genericParseJSON options

main :: IO ()
main = do
  BL.putStrLn (encode (Order 7 1250))
  print (decode "{\"id\":8,\"total_cents\":999}" :: Maybe Order)
{"id":7,"total_cents":1250}
Just (Order {orderId = 8, orderTotalCents = 999})

Hand-written instances

For full control, write the instances yourself:

{-# LANGUAGE OverloadedStrings #-}

import Data.Aeson
import qualified Data.ByteString.Lazy.Char8 as BL

data Temperature = Temperature {celsius :: Double, city :: String}
  deriving (Show)

instance ToJSON Temperature where
  toJSON t = object ["city" .= city t, "celsius" .= celsius t, "fahrenheit" .= (celsius t * 9 / 5 + 32)]

instance FromJSON Temperature where
  parseJSON = withObject "Temperature" $ \o -> do
    c <- o .: "celsius"
    name <- o .:? "city" .!= "unknown"
    if c < -273.15
      then fail "below absolute zero"
      else return (Temperature c name)

main :: IO ()
main = do
  BL.putStrLn (encode (Temperature 21.5 "Oslo"))
  print (eitherDecode "{\"celsius\": 30}" :: Either String Temperature)
  print (eitherDecode "{\"celsius\": -300}" :: Either String Temperature)
{"celsius":21.5,"city":"Oslo","fahrenheit":70.7}
Right (Temperature {celsius = 30.0, city = "unknown"})
Left "Error in $: below absolute zero"
  • .: reads a required field, .:? an optional one, .!= supplies a default.
  • fail inside a parser produces a decoding error with your message.

Use the JSON Formatter to pretty-print encoded output while debugging, and the JSON Validator to check sample payloads before writing a parser for them.