Make Illegal Payment States Unrepresentable with Haskell ADTs
Use Haskell sum types and pattern matching to make illegal payment states unrepresentable. A concrete PaymentMethod ADT shows how the compiler enforces exhaustive handling and self-documenting records, with trade-offs at serialization boundaries.
15 May 2026, 00:45 UTC

Shipping a payment form that accepts any string for "payment method" guarantees a runtime surprise. A typo like "paypal" vs "PayPal" or a credit card record without an expiry will only surface in production. Haskell’s Algebraic Data Types let you encode the valid shapes of a domain directly in the type, so the compiler rejects illegal states instead of running them.
Primitive obsession vs sum types
Primitive obsession is the habit of modeling domain concepts with raw primitives like String or Int. A status field of type String can hold "pending", "PENDING", "paid" or an empty string, all of which compile.
A sum type, also called a variant or enum, is one of the two building blocks of ADTs. Sum types say a value is one of several alternatives. Product types, like records or tuples, say a value contains several fields together. An ADT combines both.
With a sum type the set of allowed alternatives is closed. The compiler knows the complete list and can check code against it.
Pattern matching makes exhaustiveness checkable
Pattern matching is Haskell’s syntax for deconstructing data. When you match on a sum type, GHC can warn if you forget a constructor. Compile with ghc -Wall -Werror in the project directory where you have read and write permission. The flag -Wincomplete-patterns is included in -Wall. Expected check: the compiler reports a non-exhaustive pattern warning when a case expression does not cover all constructors. Risk: adding a new constructor later forces you to update all matches, which is intentional friction for safety.
Records inside variants for self-documenting models
Record syntax gives named fields to each variant, making the product part of the ADT readable. Field names are part of the type, so ccExpiry cannot be confused with paypalEmail.
Worked example: PaymentMethod
The following type makes it impossible to construct a PayPal payment without an email, or a credit card without a number and expiry.
data PaymentMethod
= CreditCard
{ ccNumber :: String
, ccExpiry :: String
, ccCvv :: String
}
| PayPal
{ paypalEmail :: String
}
| BankTransfer
{ bankIban :: String
, bankBic :: String
}
deriving (Show, Eq)
Consuming the type with pattern matching:
describe :: PaymentMethod -> String
describe (CreditCard {ccNumber = n, ..}) = "Credit Card ending " ++ take 4 n
describe (PayPal {paypalEmail = e}) = "PayPal " ++ e
describe (BankTransfer {bankIban = i}) = "Bank transfer " ++ i
If a new variant is added, e.g. ApplePay, the compiler will flag every describe site that is not updated. Record updates work as usual, e.g. pm { ccExpiry = "12/30" }, but only on the correct variant.
Trade-off: mapping to external formats
The safety of ADTs comes with boilerplate at boundaries. JSON, databases and HTTP APIs are untyped. Serializing PaymentMethod requires a library such as aeson to define ToJSON and FromJSON instances, and deserialization must validate that required fields are present for each variant. Relational storage often needs a table per variant or a tagged union with nullable columns.
Limitation: the compiler cannot enforce constraints that are not representable in the type, such as Luhn-valid card numbers or IBAN checksums. Those require smart constructors and validation at construction time.
Practical check: compile the module with ghc -Wall -Wmissing-fields -Wincomplete-patterns. Missing fields in a record pattern and incomplete matches are reported statically. For runtime confidence, add property tests around the smart constructors that build valid values.
Use sum types for the core domain model where invalid states cause real cost, keep the variants small and co-located, and isolate serialization and validation at the edges. The result is code where the type checker does the first round of domain review.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.