Blog › ICP guides

Haskell developer on retainer: typeclasses, effects, GHC extensions, and performance on monthly retainer

August 28, 2026 · ~22 min read

A financial data processing service written in Haskell was leaking memory in production. The process had been running for six weeks when the on-call engineer noticed that heap usage grew at approximately 4 gigabytes per hour under load, causing the service to be restarted every few hours by the orchestrator. The development team had already reviewed the code visually and seen nothing obviously wrong: the main processing loop looked clean, there were no apparent retained data structures, and the business logic had good test coverage. The team brought in a senior Haskell consultant on monthly retainer specifically to investigate. The consultant’s first action was to recompile the service with profiling enabled and restart it under +RTS -hc -RTS, collecting a cost-centre heap profile. After two hours of production traffic, the consultant ran hp2ps -c service.hp and opened the resulting PostScript graph.

The profile graph showed that heap was dominated by a single cost centre: a HashMap accumulator inside the main aggregation function. The function folded over a stream of incoming events using foldl to build the map. In Haskell, foldl is lazy: it builds a chain of unevaluated thunks proportional to the number of elements processed before forcing evaluation. In a long-running stream processing loop, this produced a thunk chain that grew without bound until a final force operation triggered a deep stack evaluation — and by then, the thunk chain had consumed gigabytes. The fix was precise: replace foldl with foldl' from Data.List (which forces the accumulator at each step) and add a bang pattern on the accumulator parameter of the inner loop function. After redeployment, heap usage stabilized at approximately 200 megabytes regardless of load duration. The memory growth rate dropped from 4 gigabytes per hour to effectively zero.

This kind of work — heap profiling under live load, reading cost-centre graphs, understanding the difference between weak head normal form and normal form in the context of a specific fold pattern, then applying the correct strictness annotation — produced no user-visible artifact whatsoever. The git diff showed two changed characters: an apostrophe added to foldl' and an exclamation mark added as a bang pattern. The work took 12 hours across three sessions: two hours of profiling setup and data collection, five hours of profile graph analysis and hypothesis testing across several candidate sites, and five hours of applying fixes, verifying with criterion benchmarks, and confirming the production profile was correct before declaring the issue resolved. A client reviewing the commit history would see a one-line change and have no way to recover the 12 hours from the artifact alone. This is precisely the problem that HourTab solves for Haskell consultants on retainer: a shared dashboard where each session’s hours and the investigation notes attached to them are visible to the client in real time, so the relationship between deep diagnostic work and billable hours is transparent without requiring a status email after every session.

Haskell type system

Haskell’s type system is the primary tool through which a senior consultant adds durable value. Encoding invariants in types eliminates entire classes of runtime errors; designing typeclass hierarchies that compose correctly is the architectural work that a Haskell retainer engagement is built around. This section covers the layers of the type system that come up most frequently in production Haskell advisory.

Typeclasses: Functor, Applicative, Monad, Foldable, and Traversable

The typeclass hierarchy rooted at Functor is the backbone of Haskell abstraction. A consultant advising on a new data type will always begin by determining which members of this hierarchy the type can lawfully implement, because each instance unlocks a large body of existing generic code for free.

-- The Functor class: mapping over a structure without changing its shape.
-- Laws: fmap id = id; fmap (f . g) = fmap f . fmap g
class Functor f where
  fmap :: (a -> b) -> f a -> f b

-- A custom result type for pipeline stages
data PipelineResult e a
  = PipelineError e
  | PipelineSuccess a
  deriving (Show)

instance Functor (PipelineResult e) where
  fmap _ (PipelineError e)   = PipelineError e
  fmap f (PipelineSuccess a) = PipelineSuccess (f a)

-- Applicative: applying a function inside a context to a value inside a context.
-- Laws: pure id <*> v = v; pure (.) <*> u <*> v <*> w = u <*> (v <*> w)
instance Applicative (PipelineResult e) where
  pure = PipelineSuccess
  PipelineError e   <*> _                  = PipelineError e
  _                 <*> PipelineError e    = PipelineError e
  PipelineSuccess f <*> PipelineSuccess a  = PipelineSuccess (f a)

-- Monad: sequential computation with short-circuiting on error.
instance Monad (PipelineResult e) where
  return = pure
  PipelineError e   >>= _ = PipelineError e
  PipelineSuccess a >>= f = f a

-- Foldable: reducing a structure to a summary value.
instance Foldable (PipelineResult e) where
  foldMap _ (PipelineError _)   = mempty
  foldMap f (PipelineSuccess a) = f a

-- Traversable: sequencing effects while preserving structure.
-- The killer method: turning f (g a) into g (f a).
instance Traversable (PipelineResult e) where
  traverse _ (PipelineError e)   = pure (PipelineError e)
  traverse f (PipelineSuccess a) = fmap PipelineSuccess (f a)

-- In practice: validate a list of results, collecting all into IO
processResults :: [PipelineResult String Int] -> IO ()
processResults results = do
  -- traverse with IO action; stops at first error if we use sequence
  let validated = sequenceA results
  case validated of
    PipelineError err -> putStrLn $ "Pipeline failed: " ++ err
    PipelineSuccess xs -> mapM_ print xs

Superclass constraints enforce the hierarchy. Applicative requires Functor; Monad requires Applicative. A consultant designing a custom monad for a domain-specific effect will verify that all three instances are written correctly and satisfy the respective laws before introducing the type to the codebase, because a lawless Monad instance will cause higher-order combinators like mapM, forM, and sequence to produce incorrect results in ways that are difficult to debug.

GADTs: preventing runtime errors at the type level

Generalised Algebraic Data Types allow each constructor of a data type to refine the type parameter to a more specific type. This moves entire categories of runtime invariant checks into the type checker, eliminating the corresponding pattern match failures and runtime errors from production.

{-# LANGUAGE GADTs #-}
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE KindSignatures #-}

module Expr where

import Data.Kind (Type)

-- A typed expression language.
-- The type parameter 'a' tracks the type of value the expression evaluates to.
-- Without GADTs, a single Expr type would allow ill-typed expressions
-- like (Add (Lit True) (Lit 42)), which should be a type error, not a runtime failure.
data Expr :: Type -> Type where
  Lit     :: Int                          -> Expr Int
  BoolLit :: Bool                         -> Expr Bool
  Add     :: Expr Int  -> Expr Int        -> Expr Int
  Mul     :: Expr Int  -> Expr Int        -> Expr Int
  If      :: Expr Bool -> Expr a -> Expr a -> Expr a
  Eq      :: Eq a => Expr a -> Expr a    -> Expr Bool
  Not     :: Expr Bool                   -> Expr Bool

-- The evaluator is total: no runtime type errors possible.
-- GHC knows the return type from the constructor matched.
eval :: Expr a -> a
eval (Lit n)        = n
eval (BoolLit b)    = b
eval (Add e1 e2)    = eval e1 + eval e2
eval (Mul e1 e2)    = eval e1 * eval e2
eval (If cond t f)  = if eval cond then eval t else eval f
eval (Eq e1 e2)     = eval e1 == eval e2
eval (Not e)        = not (eval e)

-- This would be a compile-time error, not a runtime error:
-- badExpr :: Expr Int
-- badExpr = Add (BoolLit True) (Lit 42)   -- ERROR: BoolLit True :: Expr Bool, not Expr Int

-- A well-typed example expression: (3 + 4) * (if True then 2 else 5)
exampleExpr :: Expr Int
exampleExpr = Mul (Add (Lit 3) (Lit 4)) (If (BoolLit True) (Lit 2) (Lit 5))
-- eval exampleExpr = 14

GADTs require the GADTs extension. They interact with ScopedTypeVariables, TypeFamilies, and RankNTypes. A senior Haskell consultant will reach for GADTs when auditing a codebase and finding pattern match functions that throw error "impossible" on branches the developer believes can never be reached — those branches are a signal that the type system is not carrying enough information, and a GADT refactor will make the impossibility explicit at the type level.

Type families and associated types

Type families are functions at the type level. They allow type-level computation and are essential for designs where the concrete representation type of a container should depend on the element type or some other type parameter.

{-# LANGUAGE TypeFamilies #-}
{-# LANGUAGE FlexibleInstances #-}

module TypeFamilies where

-- An open type family: maps a collection type to its element type.
-- This is the canonical example from the containers library design.
type family Element c

type instance Element [a]       = a
type instance Element (Maybe a) = a

-- A typeclass parameterised by container, with an associated type family.
class Container c where
  type Elem c
  empty   :: c
  insert  :: Elem c -> c -> c
  toList  :: c -> [Elem c]

-- A closed type family: all cases defined in one place.
-- Cannot be extended by downstream code (unlike open type families).
-- Useful for exhaustive type-level case analysis.
type family IsNumeric a where
  IsNumeric Int    = 'True
  IsNumeric Double = 'True
  IsNumeric Float  = 'True
  IsNumeric _      = 'False

-- Type-level natural number computation (requires DataKinds)
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE KindSignatures #-}

import GHC.TypeLits (Nat, KnownNat, natVal, type (+))
import Data.Proxy (Proxy(..))

-- A vector type whose length is tracked in its type.
-- Prevents length-mismatch errors at runtime.
data Vec :: Nat -> Type -> Type where
  VNil  :: Vec 0 a
  VCons :: a -> Vec n a -> Vec (n + 1) a

-- Safe head: only callable on non-empty vectors.
-- Vec 0 a does not match Vec (n + 1) a, so VNil cannot reach vHead.
vHead :: Vec (n + 1) a -> a
vHead (VCons x _) = x

-- Append: the length of the result is the sum of the input lengths.
vAppend :: Vec m a -> Vec n a -> Vec (m + n) a
vAppend VNil         ys = ys
vAppend (VCons x xs) ys = VCons x (vAppend xs ys)

DataKinds, DerivingVia, and RankNTypes

DataKinds promotes value-level constructors to type-level kinds, enabling phantom type parameters that carry compile-time information without runtime overhead. DerivingVia allows typeclass instances to be derived through a named newtype, eliminating boilerplate and making the derivation strategy explicit. RankNTypes allows type variables to be quantified inside function argument positions, enabling functions that are polymorphic in their callers’ choice of type.

{-# LANGUAGE DataKinds #-}
{-# LANGUAGE DerivingVia #-}
{-# LANGUAGE GeneralizedNewtypeDeriving #-}
{-# LANGUAGE RankNTypes #-}
{-# LANGUAGE ScopedTypeVariables #-}

module AdvancedTypes where

import Control.Monad.ST (ST, runST)
import Data.STRef       (newSTRef, readSTRef, writeSTRef, modifySTRef')
import Data.Kind        (Type)

-- DataKinds: promoting a data type to a kind.
-- The Role kind has no runtime values; it is purely a type-level label.
data Role = Admin | ReadOnly | Auditor

-- A phantom type parameter carries the role at the type level.
-- There is no 'role' field at runtime; the tag has zero cost.
newtype Session (r :: Role) = Session { sessionToken :: String }
  deriving (Show)

-- Functions constrained to specific roles without runtime checks:
adminAction :: Session 'Admin -> IO ()
adminAction (Session tok) = putStrLn $ "Admin action with token: " ++ tok

-- This would not compile: readOnlySession :: Session 'ReadOnly cannot be passed as Session 'Admin
-- adminAction readOnlySession  -- TYPE ERROR

-- DerivingVia: deriving instances through a named newtype.
-- Avoids copy-pasting the Num instance for each domain newtype.
newtype Dollars = Dollars { unDollars :: Double }
  deriving (Show, Eq, Ord)
  deriving (Num, Fractional, Real, RealFrac) via Double

newtype Euros = Euros { unEuros :: Double }
  deriving (Show, Eq, Ord)
  deriving (Num, Fractional, Real, RealFrac) via Double

-- QuantifiedConstraints: expressing that a constraint holds for all instantiations.
{-# LANGUAGE QuantifiedConstraints #-}

class (forall a. Eq a => Eq (f a)) => EqContainer f where
  eqEmpty :: f a -> Bool

-- RankNTypes: the ST monad for safe mutable state.
-- runST has type (forall s. ST s a) -> a
-- The 'forall s' prevents the ST state from escaping the runST boundary.
-- This is how pure mutable algorithms are written in Haskell safely.

-- An in-place accumulation that cannot expose its mutable reference:
sumWithST :: [Int] -> Int
sumWithST xs = runST $ do
  ref <- newSTRef 0
  mapM_ (\x -> modifySTRef' ref (+ x)) xs
  readSTRef ref

-- A more complex in-place sort would also use runST.
-- The forall s in (forall s. ST s a) -> a ensures that
-- any STRef s created inside cannot escape: the type variable s
-- is existentially scoped to the runST call.

-- RankNTypes also enables "continuation passing" patterns:
withValue :: (forall a. Num a => a -> r) -> r
withValue f = f (42 :: Int)  -- caller provides the polymorphic function

Effect systems and IO

Managing effects correctly is the central challenge of production Haskell engineering. The choice of effect system shapes testability, composability, and the cognitive load of reading and modifying effectful code. A Haskell consultant on retainer will typically spend significant time advising on this choice, migrating between systems, or designing interpreters that separate effect specification from effect implementation.

MTL and monad transformers

The MTL (Monad Transformer Library) approach stacks concrete monad transformers to build a composite monad with the required capabilities. It is the most widely deployed approach, found in essentially every Haskell codebase that predates the newer algebraic effect libraries.

{-# LANGUAGE GeneralizedNewtypeDeriving #-}
{-# LANGUAGE FlexibleContexts #-}

module MTLApp where

import Control.Monad.Reader (ReaderT, MonadReader, ask, runReaderT)
import Control.Monad.State  (StateT, MonadState, get, put, modify', runStateT)
import Control.Monad.Except (ExceptT, MonadError, throwError, runExceptT)
import Control.Monad.IO.Class (MonadIO, liftIO)

-- Application configuration (read-only environment)
data AppConfig = AppConfig
  { configDatabaseUrl :: String
  , configMaxRetries  :: Int
  , configLogLevel    :: String
  } deriving (Show)

-- Mutable application state
data AppState = AppState
  { stateRequestCount :: Int
  , stateErrorCount   :: Int
  } deriving (Show)

-- Application error type
data AppError
  = DatabaseError String
  | ValidationError String
  | NotFoundError String
  deriving (Show)

-- The application monad stack.
-- Reading from outermost to innermost: ReaderT wraps ExceptT wraps StateT wraps IO.
-- This ordering means short-circuiting via ExceptT does NOT discard StateT changes
-- made before the error -- important for audit log consistency.
newtype AppM a = AppM
  { unAppM :: ReaderT AppConfig (ExceptT AppError (StateT AppState IO)) a
  } deriving
  ( Functor
  , Applicative
  , Monad
  , MonadReader AppConfig
  , MonadError  AppError
  , MonadState  AppState
  , MonadIO
  )

-- Run the application monad
runAppM :: AppConfig -> AppState -> AppM a -> IO (Either AppError (a, AppState))
runAppM cfg st action =
  runStateT (runExceptT (runReaderT (unAppM action) cfg)) st

-- A typical service function: reads config, updates state, may fail
processRequest :: String -> AppM String
processRequest input = do
  config <- ask
  modify' (\s -> s { stateRequestCount = stateRequestCount s + 1 })
  if null input
    then throwError (ValidationError "Input cannot be empty")
    else do
      liftIO $ putStrLn $ "[" ++ configLogLevel config ++ "] Processing: " ++ input
      return $ "Result: " ++ input

-- The n-squared instance problem: with N effects and M monad transformers,
-- writing MTL instances requires N*M instances. The solution is to write
-- a newtype AppM and use GeneralizedNewtypeDeriving for all transformer classes.
-- For custom typeclasses, write the instance for AppM explicitly:
class MonadDatabase m where
  queryUser :: Int -> m (Maybe String)

instance MonadDatabase AppM where
  queryUser userId = do
    config <- ask
    liftIO $ putStrLn $ "Querying user " ++ show userId ++ " at " ++ configDatabaseUrl config
    return (Just "alice")  -- stub

Polysemy: algebraic effects with row polymorphism

Polysemy implements algebraic effects as a type-level list of effect rows. Each effect is declared as a GADT; interpreters reduce effects to simpler effects or to IO. The key advantage over MTL is that effects are swappable: the same call site can be run with a production interpreter (hitting a real database) or a test interpreter (returning in-memory data) without changing the function’s code.

{-# LANGUAGE TemplateHaskell #-}
{-# LANGUAGE GADTs #-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE PolyKinds #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE TypeOperators #-}

module PolysemyApp where

import Polysemy
import Polysemy.State
import Polysemy.Error
import Polysemy.Reader
import Polysemy.Output

-- Define a custom effect as a GADT.
-- Each constructor is an effectful operation.
data Database m a where
  GetUser    :: Int    -> Database m (Maybe String)
  SaveUser   :: Int    -> String -> Database m ()
  DeleteUser :: Int    -> Database m Bool

-- makeSem generates smart constructors: getUser, saveUser, deleteUser
makeSem ''Database

-- A logging effect
data Log m a where
  LogInfo  :: String -> Log m ()
  LogError :: String -> Log m ()

makeSem ''Log

-- Application logic using effects -- entirely pure in its specification.
-- The Member constraints express "this effect must be present in the row r".
processUser
  :: Members '[Database, Log, Error String, State Int] r
  => Int -> Sem r String
processUser userId = do
  modify' (+ 1)   -- State effect: increment request counter
  logInfo $ "Processing user " ++ show userId
  mUser <- getUser userId
  case mUser of
    Nothing -> do
      logError $ "User not found: " ++ show userId
      throw ("User not found: " ++ show userId)
    Just name -> do
      logInfo $ "Found user: " ++ name
      return name

-- A production interpreter: runs Database against real IO
runDatabaseIO :: Member (Embed IO) r => Sem (Database : r) a -> Sem r a
runDatabaseIO = interpret $ \case
  GetUser uid    -> embed $ do
    putStrLn $ "DB: SELECT name FROM users WHERE id = " ++ show uid
    return (Just "alice")  -- in real code: query the database
  SaveUser uid name -> embed $
    putStrLn $ "DB: INSERT INTO users VALUES (" ++ show uid ++ ", " ++ name ++ ")"
  DeleteUser uid -> embed $ do
    putStrLn $ "DB: DELETE FROM users WHERE id = " ++ show uid
    return True

-- A test interpreter: runs Database against in-memory state
-- No IO; tests are pure and fast.
runDatabasePure :: [(Int, String)] -> Sem (Database : r) a -> Sem r a
runDatabasePure _db = interpret $ \case
  GetUser uid     -> return $ lookup uid _db
  SaveUser _ _    -> return ()
  DeleteUser _    -> return True

-- A Log interpreter that writes to stdout
runLogIO :: Member (Embed IO) r => Sem (Log : r) a -> Sem r a
runLogIO = interpret $ \case
  LogInfo  msg -> embed $ putStrLn $ "[INFO]  " ++ msg
  LogError msg -> embed $ putStrLn $ "[ERROR] " ++ msg

-- Running the application in production:
runProductionApp :: IO (Either String (String, Int))
runProductionApp = do
  result <- runM
    . runError @String
    . runState @Int 0
    . runLogIO
    . runDatabaseIO
    $ processUser 42
  return $ fmap (\(s, (a, _)) -> (s, a)) result

-- Running in tests (no IO):
runTestApp :: [(Int, String)] -> Either String (String, Int)
runTestApp db = run
  . runError @String
  . fmap (\(s, (a, _)) -> (s, a))
  . runState @Int 0
  . interpret (\case { LogInfo _ -> pure (); LogError _ -> pure () })
  . runDatabasePure db
  $ processUser 42

STM: Software Transactional Memory

Haskell’s STM monad provides composable, lock-free concurrent programming. TVar, TMVar, TQueue, and TChan are the principal shared-state primitives; atomically executes an STM block in a single atomic transaction with automatic retry on conflict.

module STMConcurrency where

import Control.Concurrent.STM
import Control.Concurrent (forkIO, threadDelay)
import Control.Monad (forever, when)

-- A bounded work queue using TVars and STM.
data BoundedQueue a = BoundedQueue
  { queueItems :: TVar [a]
  , queueSize  :: TVar Int
  , queueMax   :: Int
  }

newBoundedQueue :: Int -> IO (BoundedQueue a)
newBoundedQueue maxSize = atomically $ do
  items <- newTVar []
  size  <- newTVar 0
  return $ BoundedQueue items size maxSize

-- enqueue blocks (retries) when the queue is full.
-- 'retry' abandons the current transaction and retries when any TVar it read changes.
enqueue :: BoundedQueue a -> a -> STM ()
enqueue BoundedQueue{..} item = do
  size <- readTVar queueSize
  when (size >= queueMax) retry   -- block until space is available
  modifyTVar' queueItems (item :)
  modifyTVar' queueSize  (+ 1)

-- dequeue blocks when the queue is empty.
dequeue :: BoundedQueue a -> STM a
dequeue BoundedQueue{..} = do
  items <- readTVar queueItems
  case items of
    []     -> retry               -- block until an item is available
    (x:xs) -> do
      writeTVar queueItems xs
      modifyTVar' queueSize (subtract 1)
      return x

-- orElse: try the first action; if it retries, try the second.
-- This is the key composability advantage of STM over locks.
dequeueEither :: BoundedQueue a -> BoundedQueue a -> STM a
dequeueEither q1 q2 = dequeue q1 `orElse` dequeue q2

-- A producer-consumer example using STM
producerConsumer :: IO ()
producerConsumer = do
  queue <- newBoundedQueue 10

  -- Producer: enqueue items 1..100
  _ <- forkIO $ mapM_ (\i -> atomically (enqueue queue i)) [1..100 :: Int]

  -- Consumer: dequeue and process
  _ <- forkIO $ forever $ do
    item <- atomically (dequeue queue)
    putStrLn $ "Processing item: " ++ show item
    threadDelay 10000  -- simulate work

  threadDelay 2000000  -- let it run

-- MVar for one-shot synchronisation (simpler than TVar for single handoffs)
-- IORef for single-threaded mutable state (no STM overhead)
-- TVar/STM for multi-threaded shared state requiring atomicity
-- The choice between these is a common advisory question in Haskell retainers.

GHC extensions in practice

GHC Haskell is not a single language but a base language extended by a large set of optional language pragmas. Choosing which extensions to enable, and in which modules, is an architectural decision with long-term consequences for the team’s ability to understand type errors, onboard new engineers, and migrate to future GHC versions. A Haskell consultant on retainer will frequently audit extension usage and advise on consolidating a default-extensions list in the project’s Cabal file.

Everyday extensions: OverloadedStrings, TypeApplications, LambdaCase

{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE TypeApplications  #-}
{-# LANGUAGE LambdaCase        #-}
{-# LANGUAGE MultiWayIf        #-}
{-# LANGUAGE TupleSections     #-}
{-# LANGUAGE BlockArguments    #-}
{-# LANGUAGE ScopedTypeVariables #-}

module EverydayExtensions where

import Data.Text (Text)
import qualified Data.Text as T
import Data.Aeson (decode, FromJSON)

-- OverloadedStrings: string literals become polymorphic.
-- "hello" :: Text instead of T.pack "hello"
greet :: Text -> Text
greet name = "Hello, " <> name <> "!"

-- TypeApplications: explicitly supply a type argument at a call site.
-- Eliminates ambiguity when the inferred type would be wrong or unclear.
parseConfig :: String -> Maybe Int
parseConfig s = read @Int <$> pure s  -- @Int makes the Num instance choice explicit

decodeJson :: FromJSON a => String -> Maybe a
decodeJson = decode . read @String  -- explicit Show/Read chain

-- LambdaCase: \case is syntactic sugar for \x -> case x of
describeStatus :: Int -> String
describeStatus = \case
  200 -> "OK"
  201 -> "Created"
  400 -> "Bad Request"
  404 -> "Not Found"
  500 -> "Internal Server Error"
  n   -> "Unknown status: " ++ show n

-- MultiWayIf: guards without a case expression head
classify :: Int -> String
classify n = if
  | n < 0    -> "negative"
  | n == 0   -> "zero"
  | n < 100  -> "small positive"
  | otherwise -> "large positive"

-- TupleSections: partial tuple constructors
-- (,) True is a function :: b -> (Bool, b)
tagWithTrue :: [a] -> [(Bool, a)]
tagWithTrue = map (True,)

-- BlockArguments: pass a do-block without parentheses
example :: IO ()
example = mapM_ \item -> do
  putStr "Item: "
  print item

-- ScopedTypeVariables: bring a type variable into scope in the where clause
foo :: forall a. Show a => [a] -> String
foo xs = helper xs
  where
    helper :: [a] -> String  -- 'a' here is the same 'a' as in foo's signature
    helper = concatMap (show)

RecordWildCards, NamedFieldPuns, ViewPatterns, and BangPatterns

{-# LANGUAGE RecordWildCards   #-}
{-# LANGUAGE NamedFieldPuns    #-}
{-# LANGUAGE ViewPatterns      #-}
{-# LANGUAGE BangPatterns      #-}
{-# LANGUAGE PatternSynonyms   #-}

module RecordAndPattern where

import Data.Char (toUpper)
import qualified Data.Map.Strict as Map

data User = User
  { userId    :: Int
  , userName  :: String
  , userEmail :: String
  , userAge   :: Int
  } deriving (Show)

-- RecordWildCards: bring all record fields into scope as local names.
-- Reduces repetition in functions that use many fields.
formatUser :: User -> String
formatUser User{..} =
  "User #" ++ show userId ++ ": " ++ userName ++ " <" ++ userEmail ++ "> age=" ++ show userAge

-- NamedFieldPuns: name the field and the binding uses the same name.
-- Less aggressive than RecordWildCards; more explicit than {..}
greetUser :: User -> String
greetUser User{userName, userAge} =
  "Hello " ++ userName ++ ", you are " ++ show userAge ++ " years old."

-- ViewPatterns: apply a function in a pattern position.
-- The result of the function is what the subsequent pattern matches on.
-- Useful for pattern matching on abstract types.
lookupUser :: Map.Map Int User -> Int -> String
lookupUser (Map.lookup -> Just user) _   = "Found: " ++ userName user
lookupUser _ uid                         = "Not found: " ++ show uid

-- BangPatterns: force evaluation to WHNF before binding.
-- The ! annotation on the accumulator prevents thunk accumulation in folds.
-- This is the primary tool for fixing space leaks in recursive functions.

-- BAD: lazy accumulator builds a thunk chain of size n before forcing
sumLazy :: [Int] -> Int
sumLazy = go 0
  where
    go acc []     = acc
    go acc (x:xs) = go (acc + x) xs  -- (acc + x) is a thunk, not a value

-- GOOD: strict accumulator forces evaluation at each step
sumStrict :: [Int] -> Int
sumStrict = go 0
  where
    go !acc []     = acc             -- ! forces acc to WHNF before pattern matching
    go !acc (x:xs) = go (acc + x) xs

-- PatternSynonyms: bidirectional patterns that abstract over constructors.
-- Allows changing internal representation without updating call sites.
pattern EmptyText :: String
pattern EmptyText <- (null -> True)
  where EmptyText = ""

pattern UpperFirst :: Char -> String -> String
pattern UpperFirst c cs <- (splitAt 1 -> ([c], cs))
  where UpperFirst c cs = c : cs

capitalise :: String -> String
capitalise EmptyText          = ""
capitalise (UpperFirst c cs)  = toUpper c : cs

Template Haskell

Template Haskell (TH) is GHC’s compile-time metaprogramming facility. Splices are evaluated at compile time, and their results are spliced into the module’s AST. Libraries like aeson-th, persistent, and polysemy’s makeSem use TH to generate instances and smart constructors automatically.

{-# LANGUAGE TemplateHaskell   #-}
{-# LANGUAGE DeriveGeneric     #-}
{-# LANGUAGE DeriveAnyClass    #-}
{-# LANGUAGE OverloadedStrings #-}

module TemplateHaskellExamples where

import Data.Aeson (FromJSON, ToJSON)
import Data.Aeson.TH (deriveJSON, defaultOptions, fieldLabelModifier)
import GHC.Generics (Generic)
import Language.Haskell.TH

-- DeriveGeneric + DeriveAnyClass: derive FromJSON/ToJSON via GHC.Generics.
-- Zero boilerplate for simple types.
data Product = Product
  { productId    :: Int
  , productName  :: String
  , productPrice :: Double
  } deriving (Show, Generic, FromJSON, ToJSON)

-- Template Haskell splice: deriveJSON strips a prefix from field names.
-- $(deriveJSON ...) runs at compile time and generates the instance.
data OrderLine = OrderLine
  { olQuantity :: Int
  , olProduct  :: String
  , olUnitPrice :: Double
  }

$(deriveJSON defaultOptions
  { fieldLabelModifier = drop 2 }  -- strip "ol" prefix: olQuantity -> "Quantity"
  ''OrderLine)

-- '  (single quote) refers to a value-level Name.
-- '' (double quote) refers to a type-level Name.
-- Both are used in TH splices and quasi-quotes.

-- A simple TH macro that generates a show-like function at compile time.
-- In production, TH is primarily used via library-provided splices
-- (makeSem, makeLenses, deriveJSON, mkPersist, etc.) rather than hand-written.
makeAccessor :: Name -> Q [Dec]
makeAccessor fieldName = do
  -- Generate: fieldNameOf :: SomeRecord -> FieldType
  -- (simplified -- real lens generation is more involved)
  let funName = mkName (nameBase fieldName ++ "Of")
  x <- newName "x"
  return
    [ SigD funName (AppT (AppT ArrowT WildCardT) WildCardT)
    , FunD funName [Clause [VarP x] (NormalB (VarE x)) []]
    ]

-- makeLenses from lens library is the canonical TH use in production:
-- $(makeLenses ''User) generates: userId, userName, userEmail, userAge
-- as lens optics (Lens' User Int, etc.) without writing them by hand.

Performance and profiling

Haskell’s lazy evaluation model produces performance characteristics that differ fundamentally from strict languages. Space leaks, sharing, and WHNF vs. NF distinctions require a specific profiling workflow. A Haskell consultant on retainer will typically run this workflow quarterly on production services to catch accumulating space leaks before they cause on-call incidents.

Criterion benchmarking

Criterion is the standard benchmarking library for Haskell. It handles statistical noise, warmup, and result reporting, producing mean and standard deviation for each benchmark. A Haskell retainer engagement will establish Criterion baselines before any performance optimization and re-run the suite after each change.

module Benchmarks where

import Criterion.Main
import Data.List (foldl', sort)
import qualified Data.Map.Strict as Map
import qualified Data.HashMap.Strict as HashMap

-- The data set for benchmarking
testData :: [Int]
testData = [1..10000]

-- Two implementations to compare
sumFoldl :: [Int] -> Int
sumFoldl = foldl (+) 0          -- lazy: space leak for large lists

sumFoldl' :: [Int] -> Int
sumFoldl' = foldl' (+) 0        -- strict: correct

-- nf: evaluate the result to Normal Form (fully) before timing.
-- whnf: evaluate only to Weak Head Normal Form (outer constructor).
-- Use nf for most benchmarks; whnf when the result type has no inner structure to force.
--
-- bench "name" $ nf f x  -- f applied to x, result forced to NF
-- bench "name" $ whnf f x -- f applied to x, result forced to WHNF only

-- The full benchmark suite
benchmarks :: IO ()
benchmarks = defaultMain
  [ bgroup "sum"
    [ bench "foldl  (lazy)"  $ nf sumFoldl  testData
    , bench "foldl' (strict)" $ nf sumFoldl' testData
    ]
  , bgroup "map construction"
    [ bench "Data.Map.Strict"    $ nf (foldr (\x m -> Map.insert x x m) Map.empty) testData
    , bench "Data.HashMap.Strict" $ nf (foldr (\x m -> HashMap.insert x x m) HashMap.empty) testData
    ]
  , bgroup "sort"
    [ bench "sort ascending"   $ nf sort testData
    , bench "sort descending"  $ nf sort (reverse testData)
    ]
  , bgroup "string building"
    -- whnf here because show returns a String (spine not forced until nf)
    [ bench "concatMap show"  $ nf (concatMap show) [1..1000 :: Int]
    , bench "unwords . map show" $ nf (unwords . map show) [1..1000 :: Int]
    ]
  ]

-- Running:
-- cabal run bench -- --output report.html
-- The HTML report shows timing distributions, outlier counts, and regression lines.
-- Interpreting results:
-- mean: central estimate of the benchmark time
-- std dev: spread (high std dev indicates timer noise or GC interference)
-- "outliers" line: if many outliers, GC may be interfering; run with +RTS -A256m to increase allocation area

Heap profiling and space leak investigation

The workflow for investigating a space leak in a Haskell production service follows a consistent sequence: compile with profiling, run with RTS flags, collect the heap profile, and convert it to a visual graph for analysis.

-- Compile with profiling:
-- cabal build --enable-profiling
-- or: ghc -prof -fprof-auto -rtsopts -o myservice myservice.hs

-- Run with heap profiling by cost centre (-hc):
-- ./myservice +RTS -hc -RTS
-- This generates myservice.hp during execution.

-- Convert to PostScript for visual inspection:
-- hp2ps -c myservice.hp && ps2pdf myservice.ps

-- Or use eventlog2html for modern profiling output:
-- ./myservice +RTS -l-au --eventlog -RTS
-- eventlog2html myservice.eventlog

-- The heap profiling code itself is instrumented by GHC;
-- no source changes are needed to collect profiles.

-- However, for fine-grained location information, add cost-centre annotations:
{-# SCC "aggregation-fold" #-}
aggregateEvents :: [Event] -> Summary
aggregateEvents events =
  -- The SCC annotation makes this site appear as its own cost centre
  -- in the heap profile, making it easier to identify which fold is leaking.
  foldl' step emptySummary events
  where
    step !acc event = acc <> toSummary event

-- Strictness control tools:
import Control.DeepSeq (NFData, deepseq, force, ($!!))

-- seq: force to WHNF
-- deepseq: force to NF (requires NFData instance)
-- force = id after deepseq; useful in let bindings
-- ($!!) = ($) after deepseq

data Event = Event { eventValue :: Int, eventTag :: String }
  deriving (Show)

data Summary = Summary { summaryTotal :: !Int, summaryCount :: !Int }
  deriving (Show)

instance NFData Summary where
  rnf (Summary t c) = t `seq` c `seq` ()

instance NFData Event where
  rnf (Event v t) = v `seq` rnf t

emptySummary :: Summary
emptySummary = Summary 0 0

toSummary :: Event -> Summary
toSummary e = Summary (eventValue e) 1

instance Semigroup Summary where
  Summary t1 c1 <> Summary t2 c2 = Summary (t1 + t2) (c1 + c2)

instance Monoid Summary where
  mempty = emptySummary

-- INLINE pragma: ask GHC to inline a function at every call site.
-- Useful for small, frequently called functions where the function call overhead
-- is significant or where inlining enables further optimizations (fusion rules).
{-# INLINE step #-}
stepInlined :: Summary -> Event -> Summary
stepInlined (Summary !t !c) (Event v _) = Summary (t + v) (c + 1)

-- RULES pragma: rewrite rules for compile-time algebraic simplifications.
-- The standard use is to enable list fusion: map f . map g --> map (f . g)
-- GHC's base library ships hundreds of these rules.
{-# RULES
"map/map"     forall f g xs. map f (map g xs) = map (f . g) xs
"filter/map"  forall p f xs. filter p (map f xs) = map f (filter (p . f) xs)
  #-}

Lazy evaluation pitfalls: foldl vs foldl' vs foldr

The distinction between the three standard folds is one of the most important performance concepts in Haskell and the source of most space leaks found during retainer engagements.

module FoldExplained where

import Data.List (foldl')

-- foldl: left fold, lazy accumulator.
-- For a list [1,2,3], builds:
--   (((0 + 1) + 2) + 3)
-- But each addition is UNEVALUATED until the end.
-- With a list of 10 million elements, the thunk chain is 10 million deep.
-- Stack overflow or heap exhaustion for large lists.
sumLazy :: [Int] -> Int
sumLazy = foldl (+) 0   -- AVOID for arithmetic accumulators

-- foldl': left fold, STRICT accumulator.
-- Forces the accumulator to WHNF after each step.
-- For Int and Double, WHNF = NF (scalars are fully evaluated).
-- No thunk accumulation. Constant space for the accumulator.
-- USE THIS for arithmetic and map-building folds over large lists.
sumStrict :: [Int] -> Int
sumStrict = foldl' (+) 0  -- CORRECT

-- foldr: right fold, lazy spine traversal.
-- For a list [1,2,3,4], builds:
--   1 : (2 : (3 : (4 : [])))
-- The key insight: foldr is lazy in the TAIL of the recursion.
-- This means it can work on INFINITE lists if the function f is lazy in its second argument.
-- foldr is the correct fold for building lists, Trees, and other structures lazily.
myMap :: (a -> b) -> [a] -> [b]
myMap f = foldr (\x acc -> f x : acc) []

-- For infinite lists, foldr terminates (foldr terminates when f is lazy):
take10 :: [Int]
take10 = take 10 (foldr (\x acc -> x : acc) [] [1..])  -- works

-- foldl' on an infinite list: diverges (must reach the end before returning)
-- sumStrict [1..]  -- diverges

-- The Streaming libraries (Conduit, Pipes, Streaming) solve the infinite list problem
-- for effectful processing: each element is produced, consumed, and garbage collected
-- before the next is demanded.

-- When in doubt about which fold to use:
-- Building a list or lazy structure? Use foldr.
-- Computing a strict scalar (sum, count, product)? Use foldl'.
-- Never use foldl in production code (it is almost always the wrong choice).

Streaming with Conduit

For processing large or unbounded data, Haskell’s streaming libraries provide resource-safe, constant-space pipelines. Conduit is the most widely deployed in web service contexts; Pipes and Streaming are alternatives with different design philosophies.

{-# LANGUAGE OverloadedStrings #-}

module StreamingConduit where

import Conduit
import Data.Text (Text)
import qualified Data.Text as T
import qualified Data.Text.IO as TIO
import Data.Conduit.Binary (sourceFile, sinkFile)
import Data.ByteString (ByteString)

-- Conduit pipeline structure: Source .| Conduit .| Sink
-- Source: produces values downstream
-- Conduit (transformer): consumes and produces values
-- Sink: consumes values, produces a result

-- A source that yields integers
intSource :: ConduitT () Int IO ()
intSource = yieldMany [1..1000000 :: Int]

-- A conduit that filters and transforms
evenDoubler :: ConduitT Int Int IO ()
evenDoubler = filterC even .| mapC (* 2)

-- A sink that sums the results
sumSink :: ConduitT Int Void IO Int
sumSink = foldlC (+) 0

-- Composing the pipeline: constant memory regardless of list size.
-- Each element is yielded, transformed, and consumed before the next is demanded.
runPipeline :: IO Int
runPipeline = runConduit $ intSource .| evenDoubler .| sumSink

-- A file processing pipeline
processFile :: FilePath -> FilePath -> IO ()
processFile inputPath outputPath =
  runConduitRes
    $  sourceFile inputPath          -- source: read bytes from file
    .| decodeUtf8C                   -- conduit: decode bytes to Text
    .| linesUnboundedC               -- conduit: split into lines
    .| filterC (not . T.null)        -- conduit: drop empty lines
    .| mapC T.toUpper                -- conduit: transform each line
    .| unlinesC                      -- conduit: rejoin lines
    .| encodeUtf8C                   -- conduit: encode back to bytes
    .| sinkFile outputPath           -- sink: write bytes to file

-- ResourceT ensures file handles are closed even on exception.
-- No entire file is loaded into memory at any point.

-- A more complex pipeline with a stateful conduit:
-- Emit a running total alongside each item.
runningTotal :: ConduitT Int (Int, Int) IO ()
runningTotal = go 0
  where
    go !acc = do
      mItem <- await
      case mItem of
        Nothing   -> return ()
        Just item -> do
          let acc' = acc + item
          yield (item, acc')
          go acc'

Web services with Servant

Servant is the dominant web framework for type-safe API development in Haskell. The API type is a Haskell type-level specification of the HTTP interface; the server, client, and documentation are all derived from the same type, ensuring they cannot diverge.

Defining servant API types

{-# LANGUAGE DataKinds         #-}
{-# LANGUAGE TypeOperators     #-}
{-# LANGUAGE DeriveGeneric     #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE DeriveAnyClass    #-}

module ServantAPI where

import Servant
import Data.Aeson (FromJSON, ToJSON)
import GHC.Generics (Generic)
import Data.Text (Text)
import Data.Time (UTCTime)

-- Domain types
data User = User
  { userId    :: Int
  , userName  :: Text
  , userEmail :: Text
  , createdAt :: UTCTime
  } deriving (Show, Generic, FromJSON, ToJSON)

data CreateUserRequest = CreateUserRequest
  { newUserName  :: Text
  , newUserEmail :: Text
  } deriving (Show, Generic, FromJSON, ToJSON)

data ApiError = ApiError
  { errorCode    :: Text
  , errorMessage :: Text
  } deriving (Show, Generic, FromJSON, ToJSON)

-- The API type.
-- :> chains path segments, captures, query parameters, and request bodies.
-- :<|> composes independent API branches.
-- '[JSON] specifies the content type negotiation.
type UserAPI
  =    "users"
       :> Get '[JSON] [User]
  :<|> "users"
       :> ReqBody '[JSON] CreateUserRequest
       :> PostCreated '[JSON] User
  :<|> "users"
       :> Capture "userId" Int
       :> Get '[JSON] User
  :<|> "users"
       :> Capture "userId" Int
       :> DeleteNoContent
  :<|> "users"
       :> QueryParam "search" Text
       :> QueryParam "limit" Int
       :> QueryParam "offset" Int
       :> Get '[JSON] [User]

-- A versioned API with a v1 prefix:
type API = "v1" :> UserAPI

-- The Proxy is the runtime handle for the type.
api :: Proxy API
api = Proxy

Servant server with hoistServer and custom errors

module ServantServer where

import Servant
import Servant.Server (hoistServer)
import Control.Monad.Reader (ReaderT, runReaderT, ask)
import Control.Monad.IO.Class (liftIO)
import Network.Wai (Application)
import Network.Wai.Handler.Warp (run, Settings, defaultSettings, setPort, setBeforeMainLoop)
import Data.IORef (IORef, newIORef, readIORef, modifyIORef')
import Data.Time (getCurrentTime)

-- The application monad: Reader over an environment, running in Handler.
-- Handler is servant's monad: ExceptT ServerError IO.
newtype AppM a = AppM { unAppM :: ReaderT AppEnv Handler a }
  deriving (Functor, Applicative, Monad, MonadReader AppEnv)

-- Application environment passed through ReaderT
data AppEnv = AppEnv
  { envUserStore :: IORef [User]
  , envConfig    :: AppConfig
  }

data AppConfig = AppConfig
  { configPort :: Int
  , configName :: Text
  } deriving (Show)

-- Natural transformation: AppM -> Handler (required by hoistServer)
appToHandler :: AppEnv -> AppM a -> Handler a
appToHandler env action = runReaderT (unAppM action) env

-- The server implementation
userServer :: ServerT UserAPI AppM
userServer
  =    listUsers
  :<|> createUser
  :<|> getUser
  :<|> deleteUser
  :<|> searchUsers

listUsers :: AppM [User]
listUsers = do
  env <- ask
  liftIO $ readIORef (envUserStore env)

createUser :: CreateUserRequest -> AppM User
createUser req = do
  env <- ask
  now <- liftIO getCurrentTime
  users <- liftIO $ readIORef (envUserStore env)
  let newId   = length users + 1
      newUser = User newId (newUserName req) (newUserEmail req) now
  liftIO $ modifyIORef' (envUserStore env) (newUser :)
  return newUser

getUser :: Int -> AppM User
getUser uid = do
  env <- ask
  users <- liftIO $ readIORef (envUserStore env)
  case filter ((== uid) . userId) users of
    []    -> AppM $ throwError err404 { errBody = "User not found" }
    (u:_) -> return u

deleteUser :: Int -> AppM NoContent
deleteUser uid = do
  env <- ask
  liftIO $ modifyIORef' (envUserStore env) (filter ((/= uid) . userId))
  return NoContent

searchUsers :: Maybe Text -> Maybe Int -> Maybe Int -> AppM [User]
searchUsers mSearch mLimit mOffset = do
  env <- ask
  users <- liftIO $ readIORef (envUserStore env)
  let filtered = maybe users (\q -> filter (T.isInfixOf q . userName) users) mSearch
      dropped  = maybe filtered (\o -> drop o filtered) mOffset
      taken    = maybe dropped  (\l -> take l dropped)  mLimit
  return taken

-- Wire the server with hoistServer, which applies the natural transformation
-- to convert AppM into Handler throughout the server implementation.
mkApp :: AppEnv -> Application
mkApp env = serve api (hoistServer api (appToHandler env) userServer)

-- Warp server configuration
startServer :: IO ()
startServer = do
  store <- newIORef []
  let env = AppEnv store (AppConfig 8080 "HourTab API Demo")
      settings = setPort 8080
               $ setBeforeMainLoop (putStrLn "Server started on port 8080")
               $ defaultSettings
  runSettings settings (mkApp env)

Persistent and Esqueleto for database access

{-# LANGUAGE GADTs              #-}
{-# LANGUAGE QuasiQuotes        #-}
{-# LANGUAGE TemplateHaskell    #-}
{-# LANGUAGE TypeFamilies       #-}
{-# LANGUAGE OverloadedStrings  #-}
{-# LANGUAGE DerivingStrategies #-}
{-# LANGUAGE StandaloneDeriving #-}
{-# LANGUAGE UndecidableInstances #-}
{-# LANGUAGE DataKinds          #-}
{-# LANGUAGE FlexibleInstances  #-}

module Database where

import Database.Persist.TH
import Database.Persist.Postgresql
import Database.Esqueleto.Experimental
import Data.Text (Text)
import Data.Time (UTCTime)

-- mkPersist generates entity types and their PersistEntity instances via TH.
-- The schema is defined in a quasi-quoted DSL.
share [mkPersist sqlSettings, mkMigrate "migrateAll"]
  [persistLowerCase|
    DbUser
      name    Text
      email   Text
      age     Int
      created UTCTime
      UniqueEmail email
      deriving Show

    DbPost
      title   Text
      body    Text
      author  DbUserId          -- Foreign key, typed
      created UTCTime
      deriving Show
  |]

-- A typed query using Esqueleto's experimental API.
-- The type system prevents mixing up entity types in joins.
getUsersWithPosts
  :: MonadIO m
  => SqlPersistT m [(Entity DbUser, Entity DbPost)]
getUsersWithPosts = select $ do
  (user :& post) <- from
    $ table @DbUser
    `innerJoin` table @DbPost
    `on` (\(user :& post) -> user ^. DbUserId ==. post ^. DbPostAuthor)
  where_ (user ^. DbUserAge >=. val 18)
  orderBy [asc (user ^. DbUserName), desc (post ^. DbPostCreated)]
  return (user, post)

-- Aggregation query: count posts per user
postCountPerUser
  :: MonadIO m
  => SqlPersistT m [(Value Text, Value Int)]
postCountPerUser = select $ do
  (user :& post) <- from
    $ table @DbUser
    `innerJoin` table @DbPost
    `on` (\(user :& post) -> user ^. DbUserId ==. post ^. DbPostAuthor)
  groupBy (user ^. DbUserName)
  return (user ^. DbUserName, countRows)

-- Running migrations and queries
runDatabase :: ConnectionString -> SqlPersistM a -> IO a
runDatabase connStr action = withPostgresqlPool connStr 10 $ \pool ->
  flip runSqlPersistMPool pool $ do
    runMigration migrateAll
    action

Structuring a Haskell retainer engagement

Haskell retainer work is unusually difficult to communicate to non-Haskell stakeholders, not because the work is obscure but because its most valuable outputs are absences: a space leak that no longer occurs, a class of runtime error that the type system now prevents, a test suite that can run without IO because the effect system now supports pure interpretation. Structuring the retainer so that this work is visible requires deliberate logging practices.

Scope definition: separating advisory from delivery

A Haskell retainer agreement should explicitly distinguish between at least three categories of work, each with its own hour allocation:

Feature delivery: writing new servant endpoints, implementing new business logic, extending data models. These produce user-visible output and are the easiest category to justify in a client review.

Architecture advisory: typeclass hierarchy design, effect system selection and migration, GHC extension strategy, Template Haskell tooling evaluation. These typically produce documentation, type signatures, and internal module redesigns. The artifact is a module structure that compiles and makes previously impossible code paths compile-time errors — valuable but not user-visible.

Performance and correctness work: Criterion benchmarking, heap profiling sessions, space leak investigation, STM contention analysis, WHNF vs. NF debugging. These typically produce a one-line diff (a bang pattern, a foldl' substitution, a deepseq annotation) and a graph showing the before and after heap profile. The artifact is even further removed from the work than architecture advisory.

Each category needs its own hour bucket in the retainer. If all three categories share a single bucket, feature delivery will always crowd out performance and correctness work — not because the client prefers this outcome, but because delivered features are visible and profiling sessions are not.

Session logging format for Haskell retainer work

Haskell retainer session logs should capture enough technical detail that a technically informed client can understand the connection between the hours and the outcome. A useful format:

[Category] — [Module or subsystem]. Task: [one sentence]. Work: (1) [specific action with tool or technique used] — [hours]; (2) [next action] — [hours]. Total: [hours]. Finding or deliverable: [one to three sentences]. User-visible new features: [zero / list].

Example entries for common Haskell retainer work categories:

Heap Profiling Session — EventAggregator module. Task: investigate 4 GB/hour memory growth under load. Work: (1) Compiled service with -prof -fprof-auto -rtsopts, redeployed with +RTS -hc -RTS, collected 2-hour profile — 2 hrs; (2) Ran hp2ps -c service.hp, read cost-centre graph: aggregateEvents dominated heap, identifying lazy foldl accumulator on HashMap Text Int — 3 hrs; (3) Replaced foldl with foldl', added ! bang pattern on accumulator parameter, reran Criterion benchmark to confirm constant-space behavior, redeployed and confirmed heap stabilized at 200 MB — 2 hrs. Total: 7 hrs. Finding: lazy foldl on a hot loop path was accumulating a thunk chain proportional to event stream length. Fix: two-character change (foldl' + !). User-visible new features: zero. Memory growth: eliminated.

Effect System Design Advisory — AppMonad. Task: advise on polysemy interpreter design for database effect to enable pure testing. Work: (1) Reviewed existing MonadDatabase MTL typeclass design and identified the blocking issue: the instance for AppM directly calls liftIO, making test code impossible without a real database connection — 1 hr; (2) Designed polysemy Database effect GADT with GetUser, SaveUser, DeleteUser constructors; wrote production IO interpreter and in-memory pure interpreter; updated 12 call-site modules to use Member (Database) r constraint — 4 hrs; (3) Updated test suite to use pure interpreter; 47 tests now run without any database connection; total test time dropped from 28 seconds to 4 seconds — 2 hrs. Total: 7 hrs. Deliverable: polysemy Database effect with two interpreters. User-visible new features: zero. Test suite: IO-free, 7x faster.

Using HourTab to make Haskell retainer hours visible

A Haskell consultant on retainer who does a week of space leak investigation and produces a two-character git diff has a transparency problem: the client sees the diff and not the heap graphs, the profiling sessions, or the hypothesis testing that preceded the fix. The standard response — a detailed email after each session — creates a communication overhead that compounds across a long engagement.

HourTab addresses this by giving the consultant a public, no-login dashboard URL that the client can bookmark. Each session is logged against the retainer’s hour budget. The dashboard shows current hours consumed, remaining hours, and the work log. The client can check it at any time without asking for a status update. For Haskell work specifically, the work log entries — which should follow the session logging format above — translate the technical diagnostic work into a visible record of investigation progress that a non-Haskell client can read and understand.

The alternative — quarterly invoices with hour totals and no supporting log — creates the conditions for the most common dispute in Haskell retainer engagements: the client sees 40 hours billed for a month in which no features shipped and one line changed in the codebase. With a continuously updated HourTab dashboard, the client sees the 40 hours accumulating in real time, accompanied by detailed session logs that explain each session’s diagnostic work and findings. See the HourTab pricing page for plan details; the Solo plan at $9/month covers up to 10 active retainers, which is sufficient for most independent Haskell consultants.

Retainer rates for Haskell developers

Haskell developer rates reflect a combination of factors that distinguish functional programming expertise from general software engineering: the scarcity of practitioners with commercial production Haskell experience, the depth of GHC internals knowledge required for performance-critical work, and the mathematical foundations (category theory, type theory, denotational semantics) that inform the highest-value typeclass hierarchy and effect system designs. The following rate ranges represent market rates as of 2026 for independent consultants and firms engaged on monthly retainers.

Entry-level Haskell developers (1–3 years experience)

Entry-level Haskell developers with 1 to 3 years of commercial experience typically have solid working knowledge of the Functor/Applicative/Monad hierarchy, basic MTL usage with ReaderT and ExceptT, familiarity with aeson JSON serialization, and experience with Cabal or Stack builds. They can implement straightforward servant endpoints, write QuickCheck properties, and extend existing typeclass instances. They are less likely to have experience with GADTs, type families, polysemy or fused-effects, GHC heap profiling, or Template Haskell.

Typical hourly rate: $110 to $195 per hour. Monthly retainer at 10 to 15 hours: $1,100 to $2,925 per month. Monthly retainer at 15 to 25 hours: $1,650 to $4,875 per month.

Mid-level Haskell engineers (3–8 years experience)

Mid-level Haskell engineers with 3 to 8 years of experience are proficient across the core extension set: GADTs, TypeFamilies, DataKinds, DerivingVia, RankNTypes, TypeApplications, ScopedTypeVariables, and Template Haskell. They have experience with at least one algebraic effect library beyond MTL (polysemy or fused-effects), can write and run Criterion benchmarks, understand the foldl vs. foldl' distinction and its performance implications, and have deployed servant-warp applications with hoistServer and custom middleware.

Typical hourly rate: $175 to $315 per hour. Monthly advisory retainer at 20 to 30 hours: $3,500 to $9,450 per month. Monthly full-consulting retainer at 30 to 50 hours: $5,250 to $15,750 per month.

Senior Haskell architects (8–15 years experience)

Senior Haskell architects with 8 to 15 years of experience bring deep GHC internals knowledge: Core IR reading and optimization, STG machine understanding, LLVM backend configuration, specialization and inlining pipeline control via INLINE / RULES pragmas, RTS heap layout and GC generation tuning, heap profiling workflow via hp2ps and eventlog2html, and GHC plugin development for custom type-checking passes. At the language level, they are fluent in QuantifiedConstraints, linear types (GHC 9.0+), Dependent Haskell features as they land, and the GHC 2021 / GHC 2024 standard extension sets. They can advise on effect system selection from first principles, design typeclass hierarchies that satisfy mathematical laws and compose correctly with Traversable and Distributive, and plan multi-quarter GHC version migration paths.

Typical hourly rate: $245 to $460 per hour. Monthly advisory retainer at 15 to 25 hours: $3,675 to $11,500 per month. Monthly full-consulting retainer at 25 to 50 hours: $6,125 to $23,000 per month.

Firm rates and structured retainer amounts

Haskell consulting firms and functional programming consultancies — which include firms specialising in Haskell, PureScript, and Scala, as well as general functional programming consultancies — typically bill at $205 to $370 per hour. Firm rates include overhead for account management, knowledge sharing across consultants, and continuity guarantees (the firm can substitute another qualified Haskell engineer if the primary consultant is unavailable).

Structured monthly retainer amounts as typically quoted in the market:

These ranges reflect the market as of mid-2026. The scarcity of senior Haskell practitioners means demand has consistently outpaced supply, and rates at the top of these ranges are regularly achieved by practitioners with strong public track records (open-source library authorship, GHC contributions, published work on effect systems or type-level programming).

What drives Haskell retainer rate variation

Several factors push Haskell retainer rates toward the top of the ranges above:

GHC internals depth: A consultant who can read GHC Core output, understand why a specific function is not being inlined, and apply the correct INLINE pragma or RULES rewrite to fix it commands a significant premium over a consultant who profiles at the Haskell source level only. GHC Core work is rare and difficult to evaluate in an interview.

Effect system expertise: A consultant who has migrated a large production MTL codebase to polysemy or fused-effects, written custom interpreters for non-trivial effects (database transactions, distributed tracing, circuit breaking), and designed the effect row to support both IO and pure test interpreters is substantially more valuable than one who has only used the standard MTL stack.

Domain experience: Haskell is used disproportionately in financial services (trading systems, risk engines, smart contract verification), compiler infrastructure, and distributed systems. Practitioners with domain experience in these areas can command 20 to 40 percent above the general Haskell rates.

Availability: Senior Haskell engineers are typically employed full-time or on long-term retainers. A practitioner available for new retainer work at any given time is rare. Clients who need work started immediately often pay a premium above quoted rates to secure availability.

Portfolio and reputation: Open-source Haskell library authors (particularly authors of libraries with significant reverse dependencies on Hackage), GHC contributors, and practitioners with published blog posts or papers on Haskell topics command higher rates because their expertise is independently verifiable without a lengthy evaluation process.


HourTab gives Haskell consultants a public, no-login retainer dashboard URL their clients can bookmark. No client account. No portal. Upload your hours CSV, share the link. Start free →