# Haskell Domain Errors

> Model errors as Haskell domain types with structured context, hand-written Show for humans, explicit-tag ToJSON for logs, and conscious choices about Exception/FromJSON. Use when designing error ADTs, deciding between Either/Error effect/throwIO, writing instances for errors, or reviewing error-handling code.

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

---


# Haskell Domain Errors

Errors are domain types, not strings. Each error constructor carries the structured context relevant to the failure. Rendering for humans and serialization for logs are *separate concerns* on top of that structured type. The error type *is* the data; how it surfaces (return value, effect, exception) is an orthogonal decision.

## The pattern

```haskell
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE DerivingStrategies #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE OverloadedStrings #-}

import Data.Aeson (ToJSON(..), object, (.=))
import Data.Text (Text)
import qualified Data.Text as T
import GHC.Generics (Generic)

data RegistrationError
  = EmailAlreadyExists Email
  | InvalidEmailFormat Text          -- ^ the raw input that failed
  | PasswordTooShort Int Int         -- ^ received length, minimum required
  | DatabaseUnavailable Text         -- ^ driver message
  deriving stock (Eq, Generic)

-- Human-readable Show: ready for console, log line, or UI message.
instance Show RegistrationError where
  show = \case
    EmailAlreadyExists (Email e) ->
      "Email already registered: " <> T.unpack e
    InvalidEmailFormat raw ->
      "Invalid email format: " <> show raw
    PasswordTooShort got need ->
      "Password too short: got " <> show got
        <> " chars, need at least " <> show need
    DatabaseUnavailable reason ->
      "Database unavailable: " <> T.unpack reason

-- Structured JSON: tagged discriminator + payload, suitable for log aggregators.
instance ToJSON RegistrationError where
  toJSON = \case
    EmailAlreadyExists e ->
      object [ "error" .= ("email_already_exists" :: Text)
             , "email" .= e ]
    InvalidEmailFormat raw ->
      object [ "error" .= ("invalid_email_format" :: Text)
             , "input" .= raw ]
    PasswordTooShort got need ->
      object [ "error" .= ("password_too_short" :: Text)
             , "got"   .= got
             , "min"   .= need ]
    DatabaseUnavailable reason ->
      object [ "error"  .= ("database_unavailable" :: Text)
             , "reason" .= reason ]
```

## Rules

1. **Constructors carry structured context, not pre-formatted strings.** `PasswordTooShort 4 8`, never `PasswordError "got 4, need 8"`. If you concatenate early, the structure is lost downstream — and structured logs are the whole point.

2. **`Show` is hand-written, producing a human-readable message.** This breaks the Haskell convention that `Show` round-trips with `Read`, but `Read` is rarely used in practice and a human-friendly `Show` removes the need for a separate `Display`/`Pretty` instance for most cases. Make a conscious decision: if a type genuinely needs `Read` round-trip, keep `Show` derived and add a separate function (`renderError :: MyError -> Text`) for humans.

3. **`ToJSON` uses an explicit tag + payload shape**, not Aeson's default sum-type encoding. The default (`{"tag": "EmailAlreadyExists", "contents": [...]}`) couples the Haskell constructor name to the wire format — renaming a constructor becomes a breaking change for log consumers. Use a `snake_case` discriminator under a stable key like `"error"` (or `"type"` / `"kind"` if the project prefers).

4. **`FromJSON` is optional.** Errors that flow one-way out of the system (logs, HTTP responses, telemetry) only need `ToJSON`. Add `FromJSON` only when something genuinely needs to deserialize the error back — typically tests, or when the error crosses a process boundary and needs to be reconstructed.

5. **`Exception` instance only when the error is thrown via `throwIO`.** Errors that travel through `Either` or an `Error` effect should not have `Exception` derived. Mixing throw-style and return-style for the same type is a frequent source of confusion. **Pick one channel per error type** and stick with it.

6. **Nest errors when layers compose.** When a higher-level operation can fail because a lower-level one failed, wrap rather than flatten:

   ```haskell
   data CheckoutError
     = CheckoutPaymentFailed PaymentError
     | CheckoutInventoryShort ProductId Int  -- requested
     | CheckoutCustomerBlocked CustomerId
   ```

   The `Show` and `ToJSON` instances delegate to the inner error's instances. This keeps each layer's error vocabulary focused while preserving the full chain of context.

7. **Per-app consistency matters more than universal rules.** The exact JSON shape, the discriminator key name, whether to include a `"message"` field with the human-readable form — these are project decisions. Pick a shape in the first module that needs error types and follow it everywhere in that project.

## Choosing the error channel

For each error type, decide upfront which channel it travels through:

| Channel | When to use | Type-class needs |
|---------|-------------|------------------|
| `Either MyError a` (pure return) | Pure parsing/validation, short call chains | `Show`, `ToJSON` |
| `Error MyError :> es` (effectful) | Business logic with `effectful`, errors that flow through several layers | `Show`, `ToJSON` (no `Exception`) |
| `throwIO` / `Exception` | Genuinely exceptional conditions, foreign code boundaries, bracket cleanup | `Show`, `ToJSON`, `Exception` |

See `haskell-effectful` for the `Error` effect in practice, and `haskell-logging` for how domain errors flow into structured log payloads.

