# Haskell JSON

> Serialize and deserialize Haskell types to JSON with Aeson. Covers the TaggedObject discriminator pattern for sum types, fieldLabelModifier to strip record prefixes, manual ToJSON/FromJSON for constructors without record syntax, omitNothingFields, and the generic vs manual decision. Use when writing Aeson instances, customizing JSON shape for sum types, or making serialization consistent across a codebase.

- Skill: `ivelten/haskell-json` (Agent Skill)
- Install (CLI): `npx skillmds@latest add ivelten/haskell-json`
- Raw SKILL.md: https://api.skillmd.com/api/skills/ivelten/haskell-json/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: ivelten (https://skillmd.com/u/ivelten)
- Updated: 2026-09-22
- Page: https://skillmd.com/skills/ivelten/haskell-json

---


# 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.

```haskell
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:

```json
{ "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:

```haskell
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`:

```haskell
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:

```haskell
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:

```haskell
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 `jsonb` in PostgreSQL: see `haskell-database` — `JSONBEncoded` uses these instances directly.
- Error types also need `ToJSON` for structured logs: see `haskell-domain-errors` for the explicit-tag pattern applied to error ADTs.

