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
{-# 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
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.
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.
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).
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.
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.
Nest errors when layers compose. When a higher-level operation can fail because a lower-level one failed, wrap rather than flatten:
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.
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.
1---2name: haskell-domain-errors3description: 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.4---56# Haskell Domain Errors78Errors 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.910## The pattern1112```haskell13{-# LANGUAGE DeriveGeneric #-}14{-# LANGUAGE DerivingStrategies #-}15{-# LANGUAGE LambdaCase #-}16{-# LANGUAGE OverloadedStrings #-}1718import Data.Aeson (ToJSON(..), object, (.=))19import Data.Text (Text)20import qualified Data.Text as T21import GHC.Generics (Generic)2223data RegistrationError24 = EmailAlreadyExists Email25 | InvalidEmailFormat Text -- ^ the raw input that failed26 | PasswordTooShort Int Int -- ^ received length, minimum required27 | DatabaseUnavailable Text -- ^ driver message28 deriving stock (Eq, Generic)2930-- Human-readable Show: ready for console, log line, or UI message.31instance Show RegistrationError where32 show = \case33 EmailAlreadyExists (Email e) ->34 "Email already registered: " <> T.unpack e35 InvalidEmailFormat raw ->36 "Invalid email format: " <> show raw37 PasswordTooShort got need ->38 "Password too short: got " <> show got39 <> " chars, need at least " <> show need40 DatabaseUnavailable reason ->41 "Database unavailable: " <> T.unpack reason4243-- Structured JSON: tagged discriminator + payload, suitable for log aggregators.44instance ToJSON RegistrationError where45 toJSON = \case46 EmailAlreadyExists e ->47 object [ "error" .= ("email_already_exists" :: Text)48 , "email" .= e ]49 InvalidEmailFormat raw ->50 object [ "error" .= ("invalid_email_format" :: Text)51 , "input" .= raw ]52 PasswordTooShort got need ->53 object [ "error" .= ("password_too_short" :: Text)54 , "got" .= got55 , "min" .= need ]56 DatabaseUnavailable reason ->57 object [ "error" .= ("database_unavailable" :: Text)58 , "reason" .= reason ]59```6061## Rules62631. **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.64652. **`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.66673. **`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).68694. **`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.70715. **`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.72736. **Nest errors when layers compose.** When a higher-level operation can fail because a lower-level one failed, wrap rather than flatten:7475 ```haskell76 data CheckoutError77 = CheckoutPaymentFailed PaymentError78 | CheckoutInventoryShort ProductId Int -- requested79 | CheckoutCustomerBlocked CustomerId80 ```8182 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.83847. **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.8586## Choosing the error channel8788For each error type, decide upfront which channel it travels through:8990| Channel | When to use | Type-class needs |91|---------|-------------|------------------|92| `Either MyError a` (pure return) | Pure parsing/validation, short call chains | `Show`, `ToJSON` |93| `Error MyError :> es` (effectful) | Business logic with `effectful`, errors that flow through several layers | `Show`, `ToJSON` (no `Exception`) |94| `throwIO` / `Exception` | Genuinely exceptional conditions, foreign code boundaries, bracket cleanup | `Show`, `ToJSON`, `Exception` |9596See `haskell-effectful` for the `Error` effect in practice, and `haskell-logging` for how domain errors flow into structured log payloads.