JSON Serialization with Aeson
Generic vs manual — decide upfront
| Situation | Approach |
|---|---|
| All constructors use record syntax | Generic with Options |
| Any constructor lacks record syntax | Manual instances |
| The JSON shape diverges significantly from the type structure | Manual instances |
| Simple product type with no sum | Generic with defaultOptions + fieldLabelModifier |
Mixing approaches in one type is a maintenance trap. Pick one and apply it consistently to ToJSON and FromJSON.
1. Sum types with record syntax — generic TaggedObject
When every constructor uses record syntax, genericToJSON with TaggedObject produces a flat object with a discriminator and inlined fields.
import Data.Aeson (ToJSON(..), FromJSON(..), genericToJSON, genericParseJSON, defaultOptions)
import Data.Aeson.Types (Options(..), SumEncoding(..), camelTo2)
data Payload
= TextPayload { payloadText :: Text }
| ImagePayload { payloadUrl :: Text, payloadWidth :: Int, payloadHeight :: Int }
| FilePayload { payloadFileId :: FileId, payloadMimeType :: Text }
deriving stock (Show, Eq, Generic)
payloadOptions :: Options
payloadOptions = defaultOptions
{ sumEncoding = TaggedObject { tagFieldName = "type", contentsFieldName = "data" }
, constructorTagModifier = camelTo2 '_' -- "ImagePayload" → "image_payload"
, fieldLabelModifier = camelTo2 '_' . dropPrefix "payload"
}
instance ToJSON Payload where toJSON = genericToJSON payloadOptions
instance FromJSON Payload where parseJSON = genericParseJSON payloadOptions
Output:
{ "type": "text_payload", "text": "hello" }
{ "type": "image_payload", "url": "https://...", "width": 800, "height": 600 }
{ "type": "file_payload", "file_id": "abc123", "mime_type": "application/pdf" }
Stripping the field prefix
Haskell record fields carry a type prefix (payloadUrl, payloadWidth) to avoid name clashes. Strip it before lowercasing:
import Data.Char (isUpper, toLower)
-- Drops a known camelCase prefix and lowercases the first remaining character.
-- dropPrefix "payload" "payloadUrl" = "url"
-- dropPrefix "payload" "payloadMimeType" = "mimeType" (then camelTo2 '_' → "mime_type")
dropPrefix :: String -> String -> String
dropPrefix pre s =
case stripPrefix (map toLower pre) (map toLower s) of
Just (c:rest) -> toLower c : rest
_ -> s
Then compose in fieldLabelModifier:
fieldLabelModifier = camelTo2 '_' . dropPrefix "payload"
If constructors in the same sum type have different prefixes, write manual instances instead — fieldLabelModifier is a single function applied to all fields.
2. Constructors without record syntax — manual instances
When a constructor doesn't use record syntax, TaggedObject puts its fields inside contentsFieldName rather than inlining them. Write manual instances to keep the flat format:
data Event
= UserSignedUp UserId Email -- no record syntax
| OrderPlaced OrderId [ProductId]
| PageViewed Text -- a bare scalar
deriving stock (Show, Eq, Generic)
instance ToJSON Event where
toJSON = \case
UserSignedUp uid email ->
object [ "type" .= ("user_signed_up" :: Text)
, "user_id" .= uid
, "email" .= email
]
OrderPlaced oid products ->
object [ "type" .= ("order_placed" :: Text)
, "order_id" .= oid
, "product_ids" .= products
]
PageViewed path ->
object [ "type" .= ("page_viewed" :: Text)
, "path" .= path
]
instance FromJSON Event where
parseJSON = withObject "Event" $ \o -> do
typ <- o .: "type" :: Parser Text
case typ of
"user_signed_up" -> UserSignedUp <$> o .: "user_id" <*> o .: "email"
"order_placed" -> OrderPlaced <$> o .: "order_id" <*> o .: "product_ids"
"page_viewed" -> PageViewed <$> o .: "path"
other -> fail $ "Unknown event type: " <> unpack other
Manual instances give exact control over field names, presence, and parsing logic. Prefer them whenever the type is part of a public API or stored in a database column where the shape must be stable.
3. Product types — stripping field prefixes generically
For plain records with no sum, genericToJSON with fieldLabelModifier is enough:
data UserProfile = UserProfile
{ profileName :: !Text
, profileAvatarUrl :: !(Maybe Text)
, profileBio :: !(Maybe Text)
} deriving stock (Show, Eq, Generic)
profileOptions :: Options
profileOptions = defaultOptions
{ fieldLabelModifier = camelTo2 '_' . dropPrefix "profile"
, omitNothingFields = True
}
instance ToJSON UserProfile where toJSON = genericToJSON profileOptions
instance FromJSON UserProfile where parseJSON = genericParseJSON profileOptions
omitNothingFields = True omits null keys for Maybe fields entirely, which is usually the right default for outward-facing JSON.
4. Options quick reference
| Field | Default | Common override |
|---|---|---|
sumEncoding |
defaultTaggedObject (tag + contents) |
TaggedObject { tagFieldName = "type", contentsFieldName = "data" } |
constructorTagModifier |
id |
camelTo2 '_' |
fieldLabelModifier |
id |
camelTo2 '_' . dropPrefix "myType" |
omitNothingFields |
False |
True for outward-facing JSON |
unwrapUnaryRecords |
False |
True to serialize newtype Foo = Foo { val :: Int } as just the scalar |
tagSingleConstructors |
False |
True to include the "type" key even when there's only one constructor |
5. Consistency rule
Pick one Options value per type and reuse it for both toJSON and parseJSON. A mismatch between the two is a runtime bug that only surfaces under load. A shared myTypeOptions :: Options binding enforces this.
Related
- Sum types stored as
jsonbin PostgreSQL: seehaskell-database—JSONBEncodeduses these instances directly. - Error types also need
ToJSONfor structured logs: seehaskell-domain-errorsfor the explicit-tag pattern applied to error ADTs.