Some Haskell idioms we like
Jack Kelly
2026-01-14
Scaling up our Haskell usage at Bellroy has meant cultivating a “house style” or “engineering dialect”. While a house style obviously means making decisions about the traditional Haskell decision points — choosing a preferred set of libraries and language extensions — we also believe that a good engineering dialect means forming opinions about the way we express code. Haskell is a fairly simple language under all the syntax; the same idea can have many different expressions, some much clearer than others. In this post, we’ll share a few small idioms that we’ve adopted. They might not all be novel, but we think they’re valuable enough to document.
Explicit construction in concrete Monads
Idiom: When working in a concrete monad like Maybe, Either e
or [], use that type’s data constructors explicitly instead of
calling pure.
Example:
-- This is a simplified example from one of our internal DSLs.
-- | AST for terms.
data Term
= TmBool Bool
| TmNumber Rational
| TmAnd Term Term
-- Plus some other constructors
-- | Enum of types.
data Type = TyBool | TyNumber -- Plus some others
data TypedTerm = TypedTerm Type Term
-- | Type-check a term.
--
-- If an expected type is provided, perform checking; if not
-- attempt to perform inference.
infer :: Maybe Type -> Term -> Either TypeError TypedTerm
infer expectedTy term = case term of
TmBool _ -> Right $ TypedTerm TyBool term
TmNumber _ -> Right $ TypedTerm TyNumber term
TmAnd l r -> do
TypedTerm TyBool _ <- infer (Just TyBool) l
TypedTerm TyBool _ <- infer (Just TyBool) r
Right $ TypedTerm TyBool term
-- ^^^^^
-- We use an explicit `Right` here to remind the reader that this
-- `do`-block only assembles data, and does not perform side effects.Discussion: The primary use of Haskell’s Monad typeclass is to
allow sequential description of operations “in a context” using do
blocks. Although Haskell teaching material often demonstrates Monad
instances for many data types before teaching IO, it is typically
the “stateful” monads like State or IO that have complex enough
control flow to warrant do blocks. This creates an implicit
association in some readers’ minds between do blocks and sequential,
side-effectful code, and most of our do blocks are of this
type. Writing out the “success constructor” explicitly (e.g. Just,
Right) indicates when this is not the case, and shows that no side
effects are happening here. It is a small difference, but a clarifying
habit, especially when there’s an outer do-block in an IO-like
monad and a Maybe or Either value being constructed in a let or
in a function argument.
Design modules for qualified import
Idiom: Choose names that read well when their containing module is
imported qualified. Avoid contorting identifier names to
disambiguate them from similar names in other modules.
Examples:
module Servant.Client.Handle where
-- | We say @Handle@ and not something like @ServantHandle@, so
-- that code which uses one handle can be kept more compact.
-- Code which needs to disambiguate between handles can consistently
-- refer to them as (e.g.) @Servant.Client.Handle@ or @DynamoDB.Handle@.
data Handle m = Handle {..}
addCaching :: CacheConfig -> Handle m -> Handle m
addCaching = undefined-- | Sometimes moving an enum into its own module can help.
-- Here, we can @import qualified Bellroy.Shipping.Speed as Speed@
-- and then write (e.g.) @Speed.Regular@.
module Bellroy.Shipping.Speed (Speed(..)) where
data Speed = Regular | Expedited | OvernightDiscussion: As the codebase grows, it becomes almost inevitable
that any given module will need to be imported qualified at some
point. Attempts to work around this by adding context to identifier
names (e.g. data ServantHandle m = ..., cacheServantHandle) create
awkward identifiers like Servant.cacheServantHandle when a
qualified import inevitably becomes necessary. Often the qualified
import ends up being the same or similar length as a “contorted” name
while reading more cleanly.
This idiom works best when applied to modules containing a primary type (such as a data structure or “handle”) and a collection of functions that operate mostly on that type.
Unpack large patterns using a where clause
Idiom: Keep function parameter declarations compact by unpacking
larger patterns inside a where clause. This can also be done using
let.
Examples:
-- When large patterns necessitate breaking the LHS of the `=`
-- over multiple lines, it's difficult to see where the body begins.
someFunction
(SomeRecord {field1, field2, field3})
anArg
someOtherArg@(MorePatternNoise {..}) =
body
-- Instead, move the pattern matches down into a `where`:
someFunction' someRecord anArg someOtherArg = body
where
SomeRecord {field1, field2, field3} = someRecord
MorePatternNoise {..} = someOtherArgAnother (simplified) example from real code:
shipTo :: SalesOrder -> Either Error Logistics.Import.ShipTo
shipTo SalesOrder{..} = do
-- Omitted: a bunch of code that parses and massages
-- data into the form which downstream wants.
Right
Logistics.Import.ShipTo
{ name = fromMaybe fullName' orgName,
attention = fullName' <$ orgName,
address1,
address2,
address3,
city = city',
postalCode = postalCode',
country = country',
phone,
email,
consigneeTaxId = Nothing
}
where
Address {city, country, postalCode, stateCode, stateName} =
applyShippingAddressOverrides customerShippingAddress
Attention {fullName, phone, email} = attention
-- ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
-- Instead of bulking out the pattern-match on `SalesOrder{..}`,
-- we match `Attention` here. This makes it much easier to skim
-- the parameter declaration of a function and read through its body.Use inverseMap liberally
Idiom: When writing paired injection/lookup functions (print/parse
is our most common example), use
relude’s
inverseMap function (or an equivalent) to derive the “lookup” side
from the “injection side”. The
inverseMap
function computes the partial inverse of the input function by
enumerating its domain:
inverseMap :: (Bounded a, Enum a, Ord k) => (a -> k) -> k -> Maybe aExample:
data ErrorCode = InsufficientCredits | InvalidParams | General
deriving stock (Eq, Show, Generic, Enum, Bounded)
renderErrorCode :: ErrorCode -> Text
renderErrorCode = \case
InsufficientCredits -> "insufficient_credits"
InvalidParams -> "invalid_params"
General -> "general"
parseErrorCode :: Text -> Maybe ErrorCode
parseErrorCode = inverseMap renderErrorCode
-- ^^^^^^^^^^
-- Avoids writing `parseErrorCode` by hand.Discussion:
A common cause of bugs in print/parse function pairs comes from adding
a new constructor to the data type and failing to update the
parser. Writing the parser using inverseMap makes this impossible,
because the parser is derived from the printer and GHC enforces the
completeness of the printer’s case-match.
According to Hoogle, inverseMap is only provided by the (excellent)
relude custom prelude, but is easy enough to define
independently. The only subtlety is ensuring that the intermediate map
is shared between calls:
-- Declare the `k` argument in a lambda so GHC thinks the function
-- is "fully applied" with one argument.
--
-- This could also use `enumerate :: (Bounded a, Enum a) => [a]`,
-- which arrives in base-4.22 (GHC 9.14).
inverseMap ::
forall a k.
(Bounded a, Enum a, Ord k) => (a -> k) -> k -> Maybe a
inverseMap f =
let m = Map.fromList [(f a, a) | a <- [minBound .. maxBound] :: [a]]
in \k -> Map.lookup k mTakeaways
Haskell blogs often use the language’s expressive type system and other headline features to demonstrate some very spectacular things, but there’s also a lot of expressivity in its more mundane features and through careful choices of names. Idioms like the ones we’ve shared here tend not to get the blog post treatment, but instead percolate through less formal channels like code reviews, chat and forum posts. We felt it was worth surfacing some of the ones we’ve found, because they add clarity without adding much code, and don’t impose limits on the language features we use (although we have an evolving conversation about that too). We hope you also find them useful.