Some Haskell idioms we like

Jack Kelly profile image 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 | Overnight

Discussion: 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 {..} = someOtherArg

Another (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 a

Example:

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 m

Takeaways

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.