Understanding JSON → Haskell
Records, deriving, and FromJSON.
Why Haskell JSON is unusually pleasant once you embrace deriving, and the GHC.Generics dance that produces a working decoder from a record declaration.
aeson is the library.
aeson is the de-facto Haskell JSON library — fast, well-maintained, driven by two typeclasses: FromJSON for decoding and ToJSON for encoding. A type that has instances of both can round-trip through JSON. Writing those instances by hand is tedious; the generic deriving path generates them from the record's field names automatically.
Records and generic deriving.
A Haskell record: data User = User { userId :: Int, name :: Text, avatar :: Maybe Text } deriving (Show, Generic). The Generic derived instance is what aeson hooks into. Then instance FromJSON User with no method body — the compiler synthesizes the decoder via Generic. Same for instance ToJSON User. Three lines for a fully round-trippable record.
Field naming via Options.
Haskell record fields are camelCase; JSON keys are typically snake_case. Bridge them by passing a non-default Options record: instance FromJSON User where parseJSON = genericParseJSON defaultOptions { fieldLabelModifier = camelTo2 '_' }. camelTo2 '_' turns userId into user_id for JSON purposes. The same options govern other transformations (strip prefixes, omit Nothing, tag-based encoding for sum types).
A worked example.
From { "user_id": 7, "name": "Q", "avatar": null }: {-# LANGUAGE DeriveGeneric #-}
import Data.Aeson
import GHC.Generics
data User = User
{ userId :: Int
, name :: String
, avatar :: Maybe String
} deriving (Show, Generic)
instance FromJSON User where
parseJSON = genericParseJSON defaultOptions
{ fieldLabelModifier = camelTo2 '_' }
instance ToJSON User where
toEncoding = genericToEncoding defaultOptions
{ fieldLabelModifier = camelTo2 '_' }
That's the whole DTO. The encoder uses toEncoding rather than toJSON because it streams directly to a Builder — measurably faster on large outputs.
Maybe for nullable
avatar :: Maybe String
Nothing on absent or null; Just on a present value.
JSON null or missing key → Nothing
= Type-safe optional
Sum types and discriminated unions.
A Haskell sum type is a discriminated union by language design. data Event = Click { x :: Int, y :: Int } | Submit { form :: String } plus the right Options encodes each constructor as a JSON object with a "tag" field (or a custom discriminator). Decoding pattern-matches on the tag and dispatches to the right constructor. This is one of the places where Haskell's type system makes something that's awkward in dynamically-typed languages feel natural.
Strict vs lazy fields.
Haskell records have lazy fields by default — accessing a field forces evaluation, which is fine until the JSON has been kept around for ages and you're forcing fields a long time after parse. For DTOs that should be fully evaluated at parse time, add strictness annotations with !: data User = User { userId :: !Int, name :: !Text, ... }. The aeson parser respects these and forces the fields during decode. Costs nothing measurable for typical DTOs; saves you from space-leak bugs that are unusually hard to debug.
Text vs String.
The default Haskell String is a linked list of Char — slow, memory-inefficient, almost never what you actually want. Almost every modern Haskell project uses Data.Text.Text instead — packed UTF-16, comparable to other languages' string type. Set {-# LANGUAGE OverloadedStrings #-} at the top of the file and string literals become Text automatically. Codegen tools targeting modern Haskell should emit Text, not String.