Haskell Type Design
Write the types first, then the function falls out. This is the central discipline: the type signature constrains the implementation so tightly that wrong code usually doesn't type-check.
Type-Driven Design
Domain first, functions second. Sketch the algebraic data types for the problem before any logic. Sum types (
data Status = Pending | Approved | Rejected Reason) replace state-tracking booleans and string-typed enums.Newtypes for every primitive that has meaning.
newtype UserId = UserId Int64andnewtype Email = Email Textcost nothing at runtime and prevent entire classes of bugs at the type level. Usederiving newtypefor the underlying instances you actually want exposed:newtype UserId = UserId Int64 deriving stock (Show, Eq, Ord) deriving newtype (FromJSON, ToJSON)Make functions total. No
head, nofromJust, noerrorin business logic. ReturnMaybe aorEither MyError a. Thesafepackage providesheadMayetc. if you want it.Parse, don't validate. A validation function returns
Bool; a parser returns a narrower type that carries proof of validity.- Wrong:
validateEmail :: Text -> Bool - Right:
parseEmail :: Text -> Either EmailError Email
Once you hold an
Email, you never need to check it again — the type carries the proof. "Make illegal states unrepresentable."- Wrong:
Phantom types for state machines. When state transitions must be enforced at compile time, reach for phantom type parameters with
DataKinds:data ConnState = Open | Closed data Connection (s :: ConnState) = Connection { ... } openConn :: ConnConfig -> IO (Connection 'Open) closeConn :: Connection 'Open -> IO (Connection 'Closed) send :: Connection 'Open -> ByteString -> IO ()sendon aConnection 'Closedis a compile error, not a runtime exception.
Records
Stick to the classic Haskell record style. It's verbose in one specific way (field names need a prefix to avoid clashes) but everything else about it is friction-free, works in every tutorial and library you'll read, and crucially keeps field accessors as first-class functions that compose normally — a property that NoFieldSelectors destroys.
Two shapes
Positional, for small types with obvious field semantics:
data Point = Point Double Double
data Pair a b = Pair a b
data Range = Range Int Int -- low, high
Use this when there are 2–3 fields and the order is unambiguous. Destructure positionally: \(Point x y) -> ....
Named records, for everything else:
data User = User
{ userId :: !UserId
, userEmail :: !Email
, userName :: !Text
} deriving stock (Eq, Show)
data Product = Product
{ productId :: !ProductId
, productName :: !Text
, productPrice :: !Money
} deriving stock (Eq, Show)
Note the prefix on every field. This is the deliberate trade-off: a few extra characters per field name in exchange for zero ambiguity, zero extensions, and accessors that compose:
-- Accessors are just functions. They compose, they map, they sort.
emails = map userEmail users
sorted = sortBy (comparing userName) users
totals = sum (map productPrice cart)
The ! strict-field annotations are intentional — see haskell-quality-gates for why strict-by-default avoids common space leaks.
Naming convention for field prefixes
Pick one and stick to it across the project:
- Camel-cased type name + field:
User { userId, userEmail },Product { productId, productName }. Most common convention and what you'll see in published Haskell code. Default to this. - Short abbreviation when the type name is long and used heavily:
OrderLineItem { oliQuantity, oliPrice }. Use sparingly — abbreviations are project-private vocabulary. - Underscore prefix (
_userId,_userEmail) only if you adoptlensoropticsfrom the start.makeLensesstrips the underscore to generate the lens names, so_userIdbecomes the lensuserId.
Pattern matching on records
Use NamedFieldPuns per-module when destructuring named records — it removes the noise of User { userId = uid, userEmail = e }:
{-# LANGUAGE NamedFieldPuns #-}
greet :: User -> Text
greet User{userName, userEmail} =
"Hello, " <> userName <> " (" <> coerce userEmail <> ")"
NamedFieldPuns only affects local pattern matches; it doesn't change field selectors or introduce any ambiguity. Safe to enable broadly.
When you really need deep access or update
For deeply nested reads, multi-level updates, or working with complex state, reach for the optics library (preferred over lens — cleaner errors, no operator soup). Don't try to hand-roll nested record updates; the user { profile = (profile user) { email = ... } } syntax gets ugly fast and optics exists exactly for this.
Related
- Domain errors are themselves a type-design concern — see
haskell-domain-errorsfor the error-modeling pattern.