Weyl Standard Haskell
Production Haskell guidelines optimizing for disambiguation, focusing on pragmatic patterns for web servers, compilers, and systems programming.
// hypermodern // haskell // production
Why We Do What We Do
Production Haskell exists at the intersection of mathematical beauty and economic reality. We write in a language that could express category theory but choose to express business logic instead. Not because we can’t do the former, but because making money with functional programming is the ultimate proof of concept.
If RWST was written today, it wouldn’t be a monad transformer tutorial. It would be
ReaderT Config (ExceptT AppError (StateT Metrics IO)), it would have structured logging,
Prometheus metrics, and compile with -O2 -Wall -Werror. It would process millions of events per
second while three different teams extend it without coordination. That’s the gulf between academic
Haskell and production Haskell—we’re not writing papers, we’re writing paychecks.
This guide is for practitioners who know that Applicative is powerful not because it’s a
mathematical abstraction, but because it makes JSON parsing composable. Who understand that STM
isn’t beautiful because it solves the dining philosophers problem, but because it means you can
write concurrent code at 3am without creating race conditions.
We are not the same as the Haskell you learned in university. We’re what happens when you take those ideas and make them work for money.
Core Philosophy: Optimize for Disambiguation
In modern codebases where agents generate significant amounts of code, traditional economics invert:
- Code is written once by agents in seconds
- Code is read hundreds of times by humans and agents
- Code is debugged when you’re under pressure by tired humans
- Code is modified by agents who lack the original context
Every ambiguity compounds exponentially.
-- This costs an agent 0.1 seconds to write, a human 10 minutes to debugprocess e = if p e > 0 then go e else stop
-- This costs an agent 0.2 seconds to write, saves hours of cumulative confusionprocessIncomingRequest :: HttpRequest -> IO ResponseResultprocessIncomingRequest httpRequest = if requestTimeout httpRequest > 0 then processValidRequest httpRequest else returnTimeoutErrorLanguage Extensions: A Hierarchy of Trust
Green Light - Use Freely
{-# LANGUAGE BangPatterns #-} -- Strictness is good{-# LANGUAGE OverloadedStrings #-} -- Text everywhere{-# LANGUAGE RecordWildCards #-} -- Tasteful destructuring{-# LANGUAGE NamedFieldPuns #-} -- Clear intent{-# LANGUAGE DeriveGeneric #-} -- Boring is good{-# LANGUAGE DerivingStrategies #-} -- Be explicit{-# LANGUAGE StrictData #-} -- Default strict{-# LANGUAGE NumericUnderscores #-} -- 1_000_000 is clearerYellow Light - Use With Purpose
{-# LANGUAGE TypeFamilies #-} -- OK for libraries{-# LANGUAGE GADTs #-} -- When the juice is worth the squeeze{-# LANGUAGE RankNTypes #-} -- Sometimes necessary{-# LANGUAGE FlexibleContexts #-} -- When the alternative is worse{-# LANGUAGE TemplateHaskell #-} -- For Aeson/Lens, but measure build impactRed Light - Justify Your Existence
{-# LANGUAGE DataKinds #-} -- Type-level programming rarely pays off in apps{-# LANGUAGE TypeOperators #-} -- Compile times and error messages suffer{-# LANGUAGE UndecidableInstances #-} -- Usually means you're solving the wrong problem{-# LANGUAGE ImplicitParams #-} -- Debugging nightmare{-# LANGUAGE OverlappingInstances #-} -- Semantic timebombControl Flow: Pragmatism Over Purity
The Indentation Reality Check
In production code, deep nesting isn’t just ugly—it’s a maintenance liability. Every level of indentation is a place where:
- Merge conflicts multiply
- Off-by-one space errors break compilation
- Code reviews devolve into whitespace debates
- Even good IDEs struggle with Haskell’s layout rules
-- BAD: Philosophically pure but practically painfulprocessRequest request = case validateRequest request of Nothing -> handleInvalid Just validReq -> case findRoute routes validReq of Nothing -> handleNoRoute Just route -> case lookupHandler route of Nothing -> handleMissingHandler Just handler -> executeHandler handler validReq
-- GOOD: Flat is better than nestedprocessRequest request | Nothing <- validateRequest request = handleInvalid | Just validReq <- validateRequest request , Nothing <- findRoute routes validReq = handleNoRoute | Just validReq <- validateRequest request , Just route <- findRoute routes validReq , Nothing <- lookupHandler route = handleMissingHandler | Just validReq <- validateRequest request , Just route <- findRoute routes validReq , Just handler <- lookupHandler route = executeHandler handler validReq
-- BETTER: Extract to where clause with guardsprocessRequest request = processValidated where processValidated | Nothing <- validateRequest request = handleInvalid | Just validReq <- validateRequest request = routeRequest validReq
routeRequest validReq | Nothing <- findRoute routes validReq = handleNoRoute | Just route <- findRoute routes validReq = handleRoute route validReq
handleRoute route validReq | Nothing <- lookupHandler route = handleMissingHandler | Just handler <- lookupHandler route = executeHandler handler validReqThe Production Pattern That Works
Small do-block for sequencing, where-clause with guards for logic:
-- This pattern scales to real complexity without nesting hellhandleWebRequest :: Request -> AppM ResponsehandleWebRequest request = do startTime <- getCurrentTime validated <- validateOrReject request enriched <- enrichRequest validated
result <- processRequest enriched recordMetrics startTime result return result where validateOrReject req | not (validMethod req) = throwError InvalidMethod | not (validHeaders req) = throwError InvalidHeaders | not (validBody req) = throwError InvalidBody | otherwise = pure req
enrichRequest req = do sessionId <- extractSessionId req permissions <- loadPermissions sessionId return $ req { requestSession = sessionId, requestPerms = permissions }
processRequest req | isHealthCheck req = return healthCheckResponse | needsAuth req && not (hasValidAuth req) = throwError Unauthorized | otherwise = routeToHandler reqNaming: The Three-Character Rule
If an identifier is 3 characters or less, it’s probably too short for production code:
-- BAD: Abbreviated names multiply confusioncfg <- loadCfgconn <- mkConn cfgres <- proc req
-- GOOD: Full words tell the storyconfiguration <- loadServerConfigurationconnection <- createDatabaseConnection configurationresponse <- processClientRequest requestStandard Exceptions (Use Sparingly)
Only in local scope where type makes it unambiguous:
xs, ys- lists in pure functionsm, n- indices in array algorithmsk, v- key/value in map operationsf, g- functions in higher-order contexts
But even here, consider being explicit:
-- OK for simple pure functionsmap f xs = ...
-- Better for production code where context mattersmapWithIndex :: (Int -> a -> b) -> [a] -> [b]mapWithIndex indexedFunction inputList = ...Config Parsing: The Foundation That Can’t Crack
Configuration parsing is critical because config errors multiply across every component. Parse as much as possible upfront, but pragmatically handle large services that need staged loading:
-- Parse and validate core config at startuploadSystemConfiguration :: FilePath -> IO SystemConfigurationloadSystemConfiguration configPath = do configText <- readFileText configPath parseAndValidate configText where parseAndValidate text = case parseConfigurationTOML text of Left parseError -> fatal $ "[config] [parse] [error] " <> formatParseError parseError Right rawConfig -> case validateConfiguration rawConfig of Left validationError -> fatal $ "[config] [validation] [error] " <> validationError Right validConfig -> do logInfo $ "[config] [loaded] [path :: " <> T.pack configPath <> "]" return validConfig
-- Make invalid configs unrepresentabledata ValidatedServerConfig = ValidatedServerConfig { validatedPort :: PortNumber -- newtype with bounds checking , validatedHost :: HostName -- newtype with validation , validatedMaxConnections :: PositiveInt }
newtype PortNumber = PortNumber Word16mkPortNumber :: Int -> Either Text PortNumbermkPortNumber portNumber | portNumber > 0 && portNumber <= 65535 = Right (PortNumber $ fromIntegral portNumber) | otherwise = Left $ "Invalid port: " <> T.pack (show portNumber)The Agent Collaboration Convention
This convention helps identify code provenance and reasoning:
-- Standard implementation following established patternsparseHttpRequest :: ByteString -> IO (Either ParseError HttpRequest)parseHttpRequest requestBytes = do headersParsed <- parseHeaders requestBytes bodyParsed <- parseBody requestBytes
-- human: http/1.0 clients send malformed content-length, handle gracefully let normalizedHeaders = normalizeContentLength headersParsed
buildRequest normalizedHeaders bodyParsedNewtype Wrapping: Pragmatic Boundaries
Always Wrap These
-- Domain boundaries - prevents mixing up parametersnewtype SessionId = SessionId UUIDnewtype RequestId = RequestId Int64newtype RouteId = RouteId Text
-- Units and semantics - when the type carries meaningnewtype Milliseconds = Milliseconds Int64newtype ByteCount = ByteCount Word64newtype Percentage = Percentage { unPercentage :: Double }
-- Validation boundaries - when construction can failnewtype Email = Email { unEmail :: Text }mkEmail :: Text -> Either ValidationError Email
-- Compiler domain - prevents mixing AST node typesnewtype NodeId = NodeId Intnewtype TypeId = TypeId Intnewtype ScopeLevel = ScopeLevel IntDon’t Wrap These
-- Internal module detailstype LoopCounter = Inttype CacheSize = Int
-- Well-typed contexts where confusion is unlikelydata ThreadPool = ThreadPool { poolThreadCount :: !Int , poolQueueDepth :: !Int , poolMaxIdleTime :: !NominalDiffTime }The rule: Start with type aliases, upgrade to newtypes when you find bugs mixing things up. With
-O2, GHC eliminates newtype overhead anyway.
STM: Composable Concurrency
STM shines in production because transactions compose and retry elegantly:
-- Composable operations for connection poolsallocateFromPool :: ConnectionPool -> Int -> STM (Maybe [Connection])allocateFromPool pool requestedCount = do available <- readTVar (poolAvailable pool) if length available >= requestedCount then do let (allocated, remaining) = splitAt requestedCount available writeTVar (poolAvailable pool) remaining modifyTVar (poolInUse pool) (allocated ++) return (Just allocated) else return Nothing
-- Combine multiple operations atomicallytransferConnections :: ConnectionPool -> ConnectionPool -> Int -> STM BooltransferConnections fromPool toPool connectionCount = do maybeConnections <- allocateFromPool fromPool connectionCount case maybeConnections of Nothing -> return False Just connections -> do modifyTVar (poolAvailable toPool) (connections ++) return TrueSTM’s limitations: no IO inside transactions, and very long transactions can starve. But for managing shared state, it’s unmatched.
Compiler Warnings: Your Automated Colleague
Always use strict warnings—they catch more bugs than you lose time to refactoring:
# In package.yaml or .cabalghc-options: - -Wall - -Werror - -Wincomplete-patterns - -Wincomplete-record-updates - -Wmissing-signatures - -Wname-shadowing - -Wunused-matches - -Wunused-imports-- -Wincomplete-patterns saves you from 3am crashesprocessMessage :: Message -> IO ()processMessage (TextMessage content) = sendText contentprocessMessage (BinaryMessage bytes) = sendBinary bytesprocessMessage (DataMessage payload) = processData payload-- Compiler ensures we handle all cases
-- -Wmissing-signatures prevents type confusionprocessRequest :: UnvalidatedRequest -> IO (Either RequestError Response)processRequest request = do validated <- validateRequest request handleRequest validatedState Machines in Types
-- BAD: State scattered across booleansdata Connection = Connection { isConnected :: Bool , isAuthenticated :: Bool , hasError :: Bool }
-- GOOD: State machine can't be in impossible statesdata ConnectionState = Disconnected | Connecting ConnectingInfo | Connected ConnectionInfo | Authenticated AuthInfo | Errored ErrorInfo
data Connection = Connection { connectionId :: ConnectionId , connectionState :: TVar ConnectionState }
-- Compiler state machines for type checkingdata TypeCheckState = Parsing SourceLocation | ResolvingNames SymbolTable | CheckingTypes TypeEnv | GeneratingCode CodeGenContext | CompilationComplete CompiledModule | CompilationFailed CompileErrorMetrics and Observability
Instrument what matters, with first-class metrics types:
data ServerMetrics = ServerMetrics { metricsRequestsReceived :: !Counter , metricsRequestsCompleted :: !Counter , metricsRequestLatency :: !Histogram , metricsActiveConnections :: !Gauge }
handleRequest :: ServerState -> Request -> IO ResponsehandleRequest serverState request = do startTime <- getCurrentMonotonicTime incrementCounter (metricsRequestsReceived $ serverMetrics serverState) incrementGauge (metricsActiveConnections $ serverMetrics serverState)
result <- processRequest serverState request
endTime <- getCurrentMonotonicTime recordHistogram (metricsRequestLatency $ serverMetrics serverState) (timeDiffMicros endTime startTime) decrementGauge (metricsActiveConnections $ serverMetrics serverState)
handleResult resultStructured Logging
-- Structured, parseable, grepablehandleHttpRequest :: Request -> IO ResponsehandleHttpRequest request = do logInfo $ "[http] [request] [received] [id :: " <> requestId request <> "] [method :: " <> requestMethod request <> "] [path :: " <> requestPath request <> "]"
result <- routeAndHandle request
logInfo $ "[http] [request] [complete] [id :: " <> requestId request <> "] [status :: " <> showStatus result <> "] [duration_ms :: " <> showDuration result <> "]"
return resultTesting Philosophy
Property Tests for Invariants
-- Unit tests: thorough but mechanical (agent-friendly)describe "parseHttpHeaders" $ do it "parses valid headers" $ do let input = "Content-Type: application/json\r\nContent-Length: 42\r\n" parseHttpHeaders input `shouldBe` Right expectedHeaders
-- Property tests: invariants and edge cases (human insight)prop_headerRoundTrip :: ValidHeaders -> Boolprop_headerRoundTrip headers = parseHttpHeaders (renderHeaders headers) == Right headers
prop_connectionPoolInvariants :: PoolState -> Boolprop_connectionPoolInvariants pool = let available = poolAvailableConnections pool leased = poolLeasedConnections pool in Set.null (available `Set.intersection` leased)
-- Compiler invariantsprop_typeCheckPreservesScopes :: TypedAst -> Boolprop_typeCheckPreservesScopes ast = let scopes = extractScopes ast in all scopeIsWellFormed scopesPerformance: Start Simple, Measure When It Matters
-- GOOD: Write clear code first, compile with -O2sumResponseSizes :: [HttpResponse] -> ByteCountsumResponseSizes = sum . map responseSize
-- When profiling shows bottlenecks, then optimizeparseHttpRequestFast :: ByteString -> Either ParseError HttpRequestparseHttpRequestFast input = let !method = parseMethod input !headers = parseHeaders input !body = parseBody input in HttpRequest <$> method <*> headers <*> body
-- Compiler optimization: use ByteString builders for code generationgenerateJavaScript :: [Statement] -> ByteStringgenerateJavaScript statements = toLazyByteString $ foldMap statementBuilder statements where statementBuilder stmt = case stmt of Assignment var expr -> byteString "var " <> byteString var <> byteString " = " <> exprBuilder expr <> byteString ";\n" Return expr -> byteString "return " <> exprBuilder expr <> byteString ";\n"API Evolution
-- Never break, only extend-- Original (keep forever)handleRequest :: Request -> IO Response
-- Add better version alongsidehandleRequestWithContext :: ServerContext -> Request -> IO DetailedResponse
-- Delegate old to new for consistencyhandleRequest :: Request -> IO ResponsehandleRequest = handleRequestWithContext defaultContext >=> convertResponseWeb Server Patterns
Middleware Composition
type Middleware = Handler -> Handlertype Handler = Request -> IO Response
-- Composable middleware stackapplyMiddleware :: [Middleware] -> Handler -> HandlerapplyMiddleware middleware handler = foldr ($) handler middleware
-- Common middlewareloggingMiddleware :: MiddlewareloggingMiddleware nextHandler request = do logInfo $ "[middleware] [logging] [path :: " <> requestPath request <> "]" response <- nextHandler request logInfo $ "[middleware] [logging] [status :: " <> showStatus response <> "]" return response
authMiddleware :: AuthConfig -> MiddlewareauthMiddleware authConfig nextHandler request = do validated <- validateAuth authConfig request case validated of Left authError -> return $ unauthorizedResponse authError Right authenticatedRequest -> nextHandler authenticatedRequest
-- Build server with middleware stackbuildServer :: ServerConfig -> IO ServerbuildServer config = do let baseHandler = routeRequest config let withMiddleware = applyMiddleware [ loggingMiddleware , authMiddleware (configAuth config) , metricsMiddleware (configMetrics config) ] baseHandler return $ Server config withMiddlewareRouting with Type Safety
data Route = HealthCheck | ApiV1 ApiRoute | Static FilePath
data ApiRoute = GetUser UserId | CreateUser | UpdateUser UserId | ListSessions | GetSession SessionId
parseRoute :: Request -> Either RouteError RouteparseRoute request = case requestPath request of "/health" -> Right HealthCheck path | "/api/v1/" `T.isPrefixOf` path -> parseApiRoute (T.drop 8 path) path | "/static/" `T.isPrefixOf` path -> Right $ Static (T.unpack $ T.drop 8 path) _ -> Left RouteNotFound
routeHandler :: Route -> HandlerrouteHandler route request = case route of HealthCheck -> return healthCheckResponse ApiV1 apiRoute -> handleApiRoute apiRoute request Static filePath -> serveStaticFile filePathCompiler Construction Patterns
AST Design
-- Use pattern synonyms for common AST patternsdata Expr = Var Name | Lit Literal | App Expr Expr | Lam Name Expr | Let Name Expr Expr
pattern IntLit :: Int -> Exprpattern IntLit n = Lit (LitInt n)
pattern BoolLit :: Bool -> Exprpattern BoolLit b = Lit (LitBool b)
-- Type-safe AST traversalclass Traversable t => AstTraversable t where traverseAst :: Applicative f => (Expr -> f Expr) -> t -> f t
foldAst :: Monoid m => (Expr -> m) -> Expr -> mfoldAst f expr = case expr of Var name -> f expr Lit literal -> f expr App function argument -> f expr <> foldAst f function <> foldAst f argument Lam param body -> f expr <> foldAst f body Let name value body -> f expr <> foldAst f value <> foldAst f bodyType Checking with State
type TypeCheck a = StateT TypeEnv (ExceptT TypeError IO) a
data TypeEnv = TypeEnv { envBindings :: Map Name Type , envTypeVars :: Map TypeVar Type , envScopeLevel :: ScopeLevel }
checkExpr :: Expr -> TypeCheck TypecheckExpr expr = case expr of Var name -> do env <- get case Map.lookup name (envBindings env) of Nothing -> throwError $ UnboundVariable name Just varType -> return varType
App function argument -> do functionType <- checkExpr function argumentType <- checkExpr argument case functionType of TyArrow paramType returnType | paramType == argumentType -> return returnType _ -> throwError $ TypeMismatch functionType argumentType
Lam param body -> do -- Enter new scope for lambda parameter paramType <- freshTypeVar withBinding param paramType $ do bodyType <- checkExpr body return $ TyArrow paramType bodyTypeParser Combinators for Speed
-- Use attoparsec for performanceparseModule :: Parser ModuleparseModule = do skipSpace imports <- many parseImport declarations <- many parseDeclaration return $ Module imports declarations
parseDeclaration :: Parser DeclarationparseDeclaration = parseFunctionDecl <|> parseTypeDecl <|> parseClassDecl
parseFunctionDecl :: Parser DeclarationparseFunctionDecl = do name <- parseIdentifier skipSpace symbol "::" typeSignature <- parseType skipSpace name' <- parseIdentifier when (name /= name') $ fail "function name mismatch" parameters <- many parsePattern symbol "=" body <- parseExpr return $ FunctionDecl name typeSignature parameters bodyThe Vibe Test
Good production Haskell passes these checks:
- Could you debug it during an incident without ghci?
- Could a colleague (human or AI) extend it without breaking invariants?
- Do the types prevent tomorrow’s bug?
- Is every abbreviation worth the confusion it creates?
- Does it compile fast enough for flow state?
- Will it still make sense after multiple contributors have touched it?
Required Reading
The Foundations
- “Making Ad-Hoc Polymorphism Less Ad Hoc”
- Wadler & Blott (1989)
- “Monads for Functional Programming”
- Wadler (1995)
- “Software Transactional Memory”
- Harris et al. (2005)
Production Excellence
- warp - The fastest Haskell web server
- aeson - JSON parsing that doesn’t compromise
- servant - Type-level web programming that works
- async - Concurrency for mortals
- megaparsec - Parser combinators done right
Compiler Construction
- “Implementing Functional Languages: A Tutorial” - Simon Peyton Jones
- GHC source code - Study the master
- Ormolu - Code formatter implementation patterns
Essential Resources
- “Real World Haskell” - Still relevant for the production mindset
- “Parallel and Concurrent Programming in Haskell” (Marlow) - Concurrency done right
- What I Wish I Knew When Learning Haskell - Practical guidance
Summary: Production Haskell for the Modern Era
We write Haskell like we’re building production systems, not proving theorems. In codebases where agents contribute significantly:
- Optimize for disambiguation - Every ambiguity compounds
- Make invalid states unrepresentable - Use the type system
- Be explicit about effects - IO vs STM vs pure must be obvious
- Parse config early and strictly - Config errors multiply
- Use the compiler as your pair programmer - -Wall -Werror always
- Instrument what matters - First-class metrics and structured logs
- Keep it flat - Deep nesting is a maintenance liability, not elegant code
The Haskell community optimized for elegance. We optimize for clarity at scale. Beauty in production code comes from disambiguation, not cleverness.
Write code as if a hundred contributors will extend it tomorrow, and you’ll debug it during an incident next month. Because both will happen.
We are not the same as the Haskell you learned in school. We’re what happens when you take those ideas and make them work for money.