LUNAROPS · OPERATIONAL UPLINK 100% UPTIME 1,247d POSTS 893 JEFF.MOON@LUNAROPS.DEV UTC --:--:--

Haskell: Pure Functional Programming in Practice

haskellfunctional-programmingtypesmonadscompilerstype-theoryprogramming-languages

Haskell is the language that programmers argue about on the internet and quietly respect in private. It has influenced the design of virtually every typed functional language built in the last twenty years — Scala’s type classes, Rust’s traits, Swift’s protocol extensions, and F#’s computation expressions all trace lineage to ideas Haskell formalized or popularized. Yet Haskell itself remains a niche production language, used seriously in finance, compilers, and correctness-critical infrastructure but absent from most job descriptions.

This post is not an introduction for beginners. It assumes you know at least one language well, have opinions about type systems, and want to understand Haskell deeply enough to decide whether it belongs in your toolbox — or at least understand what everyone is arguing about.


What Makes Haskell Different

Several languages call themselves functional. Haskell occupies a specific position on the spectrum that distinguishes it from Scala, Clojure, F#, and Erlang in ways that matter in practice.

Purity: No Side Effects by Default

In Haskell, a function is a mathematical function. Given the same input, it always returns the same output, and it cannot do anything else — no printing, no mutating globals, no throwing exceptions that aren’t reflected in the return type. This is not a convention or best practice; the type system enforces it.

1
2
3
4
5
6
7
-- This function is pure. The type signature is a machine-checked guarantee.
double :: Int -> Int
double x = x * 2

-- This function performs I/O. The IO in the return type is not optional.
greet :: String -> IO ()
greet name = putStrLn ("Hello, " ++ name)

The IO type in the second signature is not documentation — it is a type-level marker that tells the compiler this computation interacts with the outside world. You cannot call greet from a pure context. The type system creates a hard boundary between pure computation and effectful computation.

Why does this matter? Purity enables local reasoning. When you see a function f :: Config -> Result, you know with certainty that calling it cannot modify any global state, cannot fail with a network error, cannot write to a log. That is not a claim you can make about most functions in most languages. In Python or Java, any function call might do anything — log, mutate, throw, call an external service.

Referential Transparency

Referential transparency is a consequence of purity: any expression can be replaced by its value without changing the program’s behavior. This sounds academic but has concrete engineering value.

1
2
3
4
5
6
-- These are equivalent in Haskell. You can substitute freely.
let x = expensiveComputation input
    result = combine x x

-- Same as:
let result = combine (expensiveComputation input) (expensiveComputation input)

In a language with mutable state, the second form might call expensiveComputation twice and produce different results if it has side effects. In Haskell, the compiler can and does share computations, reorder them, and inline them without changing semantics. This is why Haskell gets to have lazy evaluation without it being surprising — the compiler knows reordering is safe.

Lazy Evaluation

Haskell is lazy by default: expressions are not evaluated until their results are needed. This is unusual enough that it warrants its own section later, but the key implication here is that function arguments are not evaluated before a function is called. The function decides whether it needs the value.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
-- This works fine because Haskell never evaluates the second argument
-- when the first is True
ifThenElse :: Bool -> a -> a -> a
ifThenElse True  x _ = x
ifThenElse False _ y = y

-- Infinite lists are legal because elements are only computed when needed
naturals :: [Integer]
naturals = [1..]

firstTen :: [Integer]
firstTen = take 10 naturals  -- [1,2,3,4,5,6,7,8,9,10]

Hindley-Milner Type Inference

Haskell’s type system is based on Hindley-Milner type inference extended with type classes and higher-kinded types. The practical effect: you rarely need to write type signatures — the compiler infers them. But unlike dynamically typed languages, the types are fully checked. You get the safety of static typing with much of the terseness of dynamic typing.

1
2
3
4
5
6
-- The compiler infers that this has type [Int] -> Int without any annotation
sumList xs = foldr (+) 0 xs

-- You can (and should) write the signature anyway for documentation
sumList :: [Int] -> Int
sumList xs = foldr (+) 0 xs

The “if it compiles, it works” phenomenon is real, and it comes from this combination: pure functions, strong types, exhaustive pattern matching, and type class constraints that encode invariants. When you satisfy the type checker in Haskell, you have genuinely proven a lot about your program’s behavior.


The Type System

Haskell’s type system is one of the most expressive in any mainstream language. Understanding its core features is the most important investment you can make when learning Haskell.

Type Classes: Not Java Interfaces

Type classes are frequently analogized to interfaces, but this comparison misleads more than it helps. The critical difference: type classes are defined separately from the types they apply to, and instances can be defined anywhere. There is no inheritance. There is no object — just a type and a set of operations.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
-- Defining a type class
class Describable a where
  describe :: a -> String

-- An existing type gets an instance without modifying it
data Color = Red | Green | Blue

instance Describable Color where
  describe Red   = "a warm red"
  describe Green = "a natural green"
  describe Blue  = "a cool blue"

-- A function that works for any Describable
printDescription :: Describable a => a -> IO ()
printDescription x = putStrLn (describe x)

The Describable a => constraint in printDescription is resolved at compile time — the compiler knows the exact type at every call site and generates (or looks up) the appropriate instance. There is no virtual dispatch table at runtime; this is static dispatch that the compiler can inline.

More importantly, you can define instances for types you don’t own:

1
2
3
-- Adding Describable to Int from the standard library
instance Describable Int where
  describe n = "the number " ++ show n

This is retroactive polymorphism, sometimes called “open classes” or “extension methods.” It lets you add functionality to any type after the fact. Java interfaces require you to own the class or use adapters; type classes require neither.

The standard library type classes form a hierarchy that encodes mathematical structure:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
-- Functor: can map over the contained value
class Functor f where
  fmap :: (a -> b) -> f a -> f b

-- Applicative: can combine effects
class Functor f => Applicative f where
  pure  :: a -> f a
  (<*>) :: f (a -> b) -> f a -> f b

-- Monad: can sequence effects
class Applicative m => Monad m where
  (>>=) :: m a -> (a -> m b) -> m b

These are not arbitrary — they correspond to algebraic structures with laws (functor laws, monad laws), and the compiler can check that your instances satisfy those laws with property testing libraries.

Kinds: Types of Types

Every type in Haskell has a kind, which is the type of a type. This sounds recursive but is straightforward in practice.

  • Int has kind * (a concrete type, sometimes written Type)
  • Maybe has kind * -> * (takes a type, returns a type)
  • Either has kind * -> * -> * (takes two types, returns a type)

Kinds matter when writing polymorphic code over type constructors:

1
2
3
4
5
6
7
-- This says f must be a type constructor that takes one type argument
class Functor (f :: * -> *) where
  fmap :: (a -> b) -> f a -> f b

-- Both Maybe and [] satisfy this kind constraint
fmap (+1) (Just 5)  -- Just 6
fmap (+1) [1,2,3]   -- [2,3,4]

Understanding kinds becomes essential when you work with advanced type-level programming.

GADTs: Generalized Algebraic Data Types

GADTs let constructors return specific instantiations of the parametrized type, enabling you to encode invariants at the type level.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
{-# LANGUAGE GADTs #-}

-- A typed expression tree
data Expr a where
  Lit  :: Int        -> Expr Int
  Bool :: Bool       -> Expr Bool
  Add  :: Expr Int -> Expr Int -> Expr Int
  If   :: Expr Bool -> Expr a -> Expr a -> Expr a

-- The evaluator is total — no runtime type errors possible
eval :: Expr a -> a
eval (Lit n)     = n
eval (Bool b)    = b
eval (Add e1 e2) = eval e1 + eval e2
eval (If cond t f)
  | eval cond = eval t
  | otherwise = eval f

-- This won't compile — type mismatch caught at compile time
-- badExpr = Add (Bool True) (Lit 1)

Without GADTs, writing a typed expression evaluator that catches type errors at compile time is difficult. With them, the type checker rejects malformed expressions before any code runs.

Type Families

Type families are type-level functions — they compute types from types:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
{-# LANGUAGE TypeFamilies #-}

-- A type family that computes the element type of a container
type family ElementType (container :: *) :: * where
  ElementType [a]        = a
  ElementType (Maybe a)  = a
  ElementType (IO a)     = a

-- Associated type families in type classes
class Container f where
  type Elem f
  empty  :: f
  insert :: Elem f -> f -> f

instance Container [a] where
  type Elem [a] = a
  empty  = []
  insert = (:)

Type families enable patterns like heterogeneous collections, type-safe printf-style formatting, and extensible records.

Phantom Types and Newtype

Phantom types use a type parameter that appears in the type but not in the data, purely to carry compile-time information:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
{-# LANGUAGE GeneralizedNewtypeDeriving #-}

-- The phantom type parameter marks whether a string has been sanitized
data Sanitized
data Unsanitized

newtype HtmlInput s = HtmlInput { unHtml :: String }

-- Only this function produces a sanitized value
sanitize :: HtmlInput Unsanitized -> HtmlInput Sanitized
sanitize (HtmlInput s) = HtmlInput (escapeHtml s)

-- This function only accepts sanitized inputs
renderToPage :: HtmlInput Sanitized -> IO ()
renderToPage (HtmlInput s) = putStrLn s

-- This won't compile — you can't pass unsanitized input to the renderer
-- This is enforced at zero runtime cost

The newtype keyword creates a wrapper with identical runtime representation to the wrapped type — no boxing, no overhead. Combined with phantom types, it lets you encode rich constraints with zero cost.

Deriving strategies control how deriving generates instances:

1
2
3
4
5
6
7
8
{-# LANGUAGE DerivingStrategies #-}
{-# LANGUAGE GeneralizedNewtypeDeriving #-}
{-# LANGUAGE DerivingVia #-}

newtype UserId = UserId Int
  deriving stock   (Show, Eq, Ord, Generic)    -- use standard derived instances
  deriving newtype (Num, ToJSON, FromJSON)      -- delegate to the wrapped type
  deriving via (Sum Int) instance Semigroup UserId  -- use Sum's Semigroup

DerivingVia is particularly powerful — it lets you derive instances by specifying a type to use as the implementation template, eliminating boilerplate without sacrificing precision.


Algebraic Data Types

Haskell’s data types are algebraic: they are built from sums (OR) and products (AND). Most mainstream languages have product types (structs, records), but sum types (tagged unions) are the part that changes how you model problems.

Sum Types

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
-- A sum type: a Shape is EITHER a Circle OR a Rectangle OR a Triangle
data Shape
  = Circle    Double           -- radius
  | Rectangle Double Double    -- width, height
  | Triangle  Double Double Double  -- sides

-- Pattern matching on sum types is exhaustive — the compiler warns if you miss a case
area :: Shape -> Double
area (Circle r)        = pi * r * r
area (Rectangle w h)   = w * h
area (Triangle a b c)  =
  let s = (a + b + c) / 2
  in sqrt (s * (s-a) * (s-b) * (s-c))

If you add a Pentagon constructor later and forget to handle it in area, GHC warns you with -Wall. This is pattern match exhaustiveness checking, and it turns what would be a runtime crash in most languages into a compile-time warning.

Product Types and Record Syntax

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
-- A product type: a User has BOTH a name AND an email AND an age
data User = User
  { userName  :: String
  , userEmail :: String
  , userAge   :: Int
  } deriving (Show, Eq)

-- Record syntax generates accessor functions automatically
getEmail :: User -> String
getEmail = userEmail

-- Record update syntax creates a new value with some fields changed
birthday :: User -> User
birthday u = u { userAge = userAge u + 1 }

Maybe and Either as Canonical Error Types

Haskell’s approach to errors is to make them visible in the type system. No exceptions by default; instead, types that encode the possibility of failure.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
-- Maybe: computation that might produce no result
safeHead :: [a] -> Maybe a
safeHead []    = Nothing
safeHead (x:_) = Just x

safeDiv :: Int -> Int -> Maybe Int
safeDiv _ 0 = Nothing
safeDiv x y = Just (x `div` y)

-- Either: computation that produces a result or an error with detail
data DatabaseError
  = ConnectionFailed String
  | QueryTimeout Int
  | NotFound String
  deriving Show

lookupUser :: Int -> Either DatabaseError User
lookupUser userId
  | userId <= 0 = Left (NotFound ("invalid id: " ++ show userId))
  | otherwise   = Right (User "Alice" "alice@example.com" 30)

The caller is forced to handle both cases — the type prevents ignoring errors. This is the Haskell answer to “what if this fails?” You encode it in the return type.


Monads Demystified

Monads have accumulated an unfortunate mythology — tutorials compare them to burritos, space suits, or nuclear waste containers. These analogies obscure something that is actually precise and learnable.

A monad is a type class with three things:

  1. A way to wrap a value: return :: a -> m a (also called pure)
  2. A way to sequence operations: (>>=) :: m a -> (a -> m b) -> m b (pronounced “bind”)
  3. Two laws: left identity, right identity, and associativity

That is the entire definition. The interesting part is not the definition — it is that dozens of different computational patterns (optionality, error propagation, state, I/O, concurrency, parsing, logging) all satisfy this interface, which means code written against the interface works for all of them.

Do Notation Desugared

do notation is syntactic sugar. Every do block expands to >>= chains:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
-- do notation
result :: Maybe Int
result = do
  x <- safeDiv 10 2
  y <- safeDiv x  1
  return (x + y)

-- Exactly equivalent after desugaring
result' :: Maybe Int
result' =
  safeDiv 10 2 >>= \x ->
  safeDiv x  1 >>= \y ->
  return (x + y)

The <- in do notation is not assignment — it is bind. It extracts the value from the monadic context and names it. If the extraction fails (for Maybe, if it is Nothing), the entire chain short-circuits and returns the failure.

The IO Monad: Side Effects Without Breaking Purity

The IO monad is how Haskell reconciles pure functions with a world that requires printing, reading files, and making network requests. The key insight: IO a is not “a value of type a” — it is a description of an action that, when executed, will produce a value of type a.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
-- This is a pure value: a description of reading a line
readLineAction :: IO String
readLineAction = getLine

-- This is also a pure value: a description of printing
printAction :: String -> IO ()
printAction = putStrLn

-- Combining descriptions is pure too
greetUser :: IO ()
greetUser = do
  putStrLn "What's your name?"
  name <- getLine
  putStrLn ("Hello, " ++ name ++ "!")

-- main is the single point where GHC executes IO actions
main :: IO ()
main = greetUser

Haskell’s runtime executes main, which executes IO actions in sequence. Inside your program, you are always building descriptions of computations; only the runtime performs them. This is why purity is preserved — a function that returns IO String is a pure function that returns a description. The execution happens outside your code.

The Maybe Monad: Chaining Nullable Computations

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
data Config = Config
  { host :: String
  , port :: Int
  , path :: String
  }

-- Parse a URL into components, failing at any step if the format is wrong
parseUrl :: String -> Maybe (String, Int, String)
parseUrl url = do
  let parts = splitOn ":" url
  host <- safeHead parts
  rest <- safeIndex 1 parts
  let portAndPath = splitOn "/" rest
  portStr <- safeHead portAndPath
  port    <- readMaybe portStr
  path    <- safeIndex 1 portAndPath
  return (host, port, "/" ++ path)

-- Each <- extracts a value; if any step returns Nothing, the whole thing is Nothing
-- No nested if-null checks, no null pointer exceptions, no Optional.of().orElse()

Compare this to the equivalent in Java: four levels of Optional.flatMap, or a cascade of null checks that is easy to get wrong and hard to read. The Maybe monad makes the happy path linear while guaranteeing the error path is handled.

The Either Monad: Error Propagation

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
data AppError
  = InvalidInput String
  | DatabaseError String
  | NetworkError String
  deriving Show

validateEmail :: String -> Either AppError String
validateEmail email
  | '@' `elem` email = Right email
  | otherwise         = Left (InvalidInput ("not a valid email: " ++ email))

validateAge :: Int -> Either AppError Int
validateAge age
  | age >= 0 && age <= 150 = Right age
  | otherwise               = Left (InvalidInput ("age out of range: " ++ show age))

createUser :: String -> String -> Int -> Either AppError User
createUser name email age = do
  validEmail <- validateEmail email
  validAge   <- validateAge age
  return (User name validEmail validAge)

-- Usage
main :: IO ()
main = case createUser "Alice" "alice@example.com" 30 of
  Left err   -> putStrLn ("Error: " ++ show err)
  Right user -> putStrLn ("Created: " ++ show user)

The Either monad threads errors through a computation automatically. The first Left encountered short-circuits the chain. The caller gets a concrete error type, not an exception that might or might not be caught depending on the call stack.

The State Monad

The State monad sequences computations that read and modify a piece of state, without using mutable variables:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import Control.Monad.State

-- State s a: a computation that reads/modifies state of type s and returns a value of type a
type Counter = State Int

increment :: Counter ()
increment = modify (+1)

getCount :: Counter Int
getCount = get

-- Running stateful computations purely
countItems :: [a] -> Int
countItems xs = execState (mapM_ (\_ -> increment) xs) 0

-- More complex state threading
processItems :: [String] -> (Int, [String])
processItems items = runState computation 0
  where
    computation = do
      results <- mapM processOne items
      count   <- getCount
      return results
    processOne item = do
      increment
      return (map toUpper item)
  where toUpper = map Data.Char.toUpper

There are no mutable variables here. State Int is a function Int -> (a, Int) under the hood — the state is threaded through the computation as an argument and return value, but the monad syntax hides the plumbing. The result is purely functional code that reads like imperative code.

Monad Transformers

Real programs need multiple effects simultaneously — IO plus error handling plus state. Monad transformers stack monadic effects:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
import Control.Monad.Trans.Class
import Control.Monad.Trans.State
import Control.Monad.Trans.Except

-- A computation that can fail with AppError, maintain Int state, and do IO
type App a = ExceptT AppError (StateT Int IO) a

runApp :: App a -> IO (Either AppError a, Int)
runApp action = runStateT (runExceptT action) 0

example :: App String
example = do
  lift (lift (putStrLn "Starting..."))  -- lift through both transformers
  count <- lift get                      -- lift through one transformer
  when (count > 10) $
    throwError (InvalidInput "count too high")
  lift (modify (+1))
  return "done"

The mtl library (monad transformer library) provides type classes that make this cleaner — instead of explicit lift calls, operations work automatically at any level of the stack if the appropriate constraint is satisfied.


Lazy Evaluation

Haskell evaluates expressions lazily: a thunk (a suspended computation) is created but not evaluated until its value is demanded. This is the default — strict evaluation requires explicit annotation.

Thunks and Infinite Lists

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
-- Infinite list of Fibonacci numbers — perfectly legal
fibs :: [Integer]
fibs = 0 : 1 : zipWith (+) fibs (tail fibs)

-- Take only what you need
firstTwenty :: [Integer]
firstTwenty = take 20 fibs
-- [0,1,1,2,3,5,8,13,21,34,55,89,144,233,377,610,987,1597,2584,4181]

-- Sieve of Eratosthenes, lazily
primes :: [Int]
primes = sieve [2..]
  where
    sieve (p:xs) = p : sieve [x | x <- xs, x `mod` p /= 0]
    sieve []     = []

-- First hundred primes
first100Primes :: [Int]
first100Primes = take 100 primes

-- Infinite list of powers of 2
powersOf2 :: [Integer]
powersOf2 = iterate (*2) 1
-- [1, 2, 4, 8, 16, ...]

-- First power of 2 greater than 1000
firstOver1000 :: Integer
firstOver1000 = head (dropWhile (<= 1000) powersOf2)
-- 1024

Laziness enables programming styles that are impossible or extremely verbose in strict languages. Generators in Python are an explicit, limited version of what Haskell’s lazy lists give you automatically.

Where Laziness Helps

Modularity: Laziness separates the producer of a sequence from the consumer. A function that generates an infinite list and a function that consumes part of it are decoupled. Neither needs to know about the other’s size requirements.

Short-circuit evaluation: Any user-defined function can short-circuit. The built-in && and || are not special — any function can decide not to evaluate arguments.

Compositional pipelines: You can compose transformations over lazy lists without materializing intermediate results:

1
2
3
4
-- This processes each element once, in constant space
-- No intermediate lists are built
process :: [Int] -> Int
process = sum . filter even . map (*2)

Space Leaks: Where Laziness Bites

Laziness has a dark side: space leaks. When you accumulate thunks (unevaluated expressions) faster than you evaluate them, memory grows unboundedly. The canonical trap:

1
2
3
4
5
6
7
8
-- This builds a chain of (((0+1)+2)+3)+... thunks before evaluating
-- It uses O(n) space
badSum :: [Int] -> Int
badSum = foldl (+) 0

-- This is strict in the accumulator — O(1) space
goodSum :: [Int] -> Int
goodSum = foldl' (+) 0  -- foldl' from Data.List forces the accumulator

The foldl' (strict fold) evaluates the accumulator after each step. foldl (lazy fold) builds a tower of thunks. For large lists, foldl crashes with a stack overflow; foldl' runs in constant space.

Tools for Controlling Evaluation

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
-- seq: evaluate first argument to WHNF (weak head normal form) before returning second
forceFirst :: a -> b -> b
forceFirst x y = x `seq` y

-- $! : strict function application
strictApply :: (a -> b) -> a -> b
f $! x = x `seq` f x

-- BangPatterns: force evaluation of a pattern match argument
{-# LANGUAGE BangPatterns #-}
strictSum :: [Int] -> Int
strictSum = go 0
  where
    go !acc []     = acc            -- ! forces acc to be evaluated
    go !acc (x:xs) = go (acc + x) xs

-- Data.Map.Strict vs Data.Map.Lazy
-- Always prefer Data.Map.Strict unless you have a specific reason for laziness
import qualified Data.Map.Strict as Map

A practical rule: use strict data structures by default (Data.Map.Strict, Data.HashMap.Strict, Data.Vector), reserve lazy data structures for cases where you genuinely need infinite or on-demand computation, and use foldl' not foldl.

Understanding the difference between WHNF (weak head normal form) and NF (normal form) matters here. seq only forces to WHNF — it evaluates the outermost constructor but not the contents. For deep forcing, use deepseq from the deepseq package.


Real-World Libraries and Frameworks

Servant: Type-Safe HTTP APIs

Servant is the best demonstration of Haskell’s type system solving a real engineering problem. The API definition is a type, not a string or a YAML file:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
{-# LANGUAGE DataKinds     #-}
{-# LANGUAGE TypeOperators #-}

import Servant

-- The API type is the specification
type UserAPI =
       "users" :> Get '[JSON] [User]                              -- GET /users
  :<|> "users" :> Capture "id" Int :> Get '[JSON] User            -- GET /users/:id
  :<|> "users" :> ReqBody '[JSON] CreateUserRequest :> Post '[JSON] User  -- POST /users

-- Handler implementations must match the type exactly
userServer :: Server UserAPI
userServer = listUsers :<|> getUser :<|> createUser
  where
    listUsers :: Handler [User]
    listUsers = return [User "Alice" "alice@example.com" 30]

    getUser :: Int -> Handler User
    getUser userId = return (User "Alice" "alice@example.com" 30)

    createUser :: CreateUserRequest -> Handler User
    createUser req = return (User (reqName req) (reqEmail req) 25)

-- Running the server
app :: Application
app = serve (Proxy :: Proxy UserAPI) userServer

main :: IO ()
main = run 8080 app

What makes Servant remarkable is what it derives automatically from the type. The same type that defines the server can generate:

  • A client library: client (Proxy :: Proxy UserAPI) gives you typed Haskell functions for each endpoint
  • OpenAPI/Swagger documentation
  • QuickCheck generators for property testing

A type error in your handler (returning the wrong type, missing a parameter) is a compile error. You cannot ship a server where the implementation does not match the declared API.

Aeson: JSON with Derived Instances

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
{-# LANGUAGE DeriveGeneric #-}

import Data.Aeson
import GHC.Generics

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

-- One-line derivation of both directions
instance FromJSON User
instance ToJSON   User

-- Custom field naming with options
instance FromJSON User where
  parseJSON = genericParseJSON defaultOptions
    { fieldLabelModifier = camelTo2 '_' . drop 4 }
    -- Strips "user" prefix and converts to snake_case
    -- userName -> name, userEmail -> email

-- Manual instance when you need full control
instance ToJSON User where
  toJSON u = object
    [ "name"  .= userName u
    , "email" .= userEmail u
    , "age"   .= userAge u
    ]

For complex nested types, the Generic-derived instances handle the recursion automatically. Parsing is total — you get an error rather than a crash when JSON doesn’t match the expected shape.

Persistent and Esqueleto: Database Access

Persistent provides type-safe database access using Template Haskell to generate the schema types at compile time:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
{-# LANGUAGE GADTs                      #-}
{-# LANGUAGE GeneralizedNewtypeDeriving #-}
{-# LANGUAGE MultiParamTypeClasses      #-}
{-# LANGUAGE OverloadedStrings          #-}
{-# LANGUAGE QuasiQuotes                #-}
{-# LANGUAGE TemplateHaskell            #-}
{-# LANGUAGE TypeFamilies               #-}

import Database.Persist
import Database.Persist.TH
import Database.Persist.Sqlite

-- Schema defined as a quasi-quoted DSL, generates Haskell types
share [mkPersist sqlSettings, mkMigrate "migrateAll"] [persistLowerCase|
User
  name  String
  email String
  age   Int
  UniqueEmail email
  deriving Show

Post
  title   String
  content String
  author  UserId
  deriving Show
|]

-- Type-safe queries
main :: IO ()
main = runSqlite "blog.db" $ do
  runMigration migrateAll

  -- Insert returns a typed key
  aliceId <- insert (User "Alice" "alice@example.com" 30)

  -- Query with typed filters
  users <- selectList [UserAge >=. 18] [Asc UserName]

  -- Get by primary key
  mUser <- get aliceId

  return ()

Esqueleto extends Persistent with a type-safe SQL DSL for joins and complex queries:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
import Database.Esqueleto.Experimental

-- Type-safe join
getPostsWithAuthors :: SqlPersistT IO [(Entity Post, Entity User)]
getPostsWithAuthors =
  select $ do
    (post :& user) <- from $
      table @Post `innerJoin` table @User
        `on` (\(post :& user) -> post ^. PostAuthor ==. user ^. UserId)
    orderBy [asc (post ^. PostTitle)]
    return (post, user)

The column references (PostAuthor, PostTitle) are generated by Template Haskell and type-checked. There are no stringly-typed column names to typo.

Conduit: Streaming Data

Conduit provides safe, resource-efficient streaming with automatic resource management:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import Conduit

-- Process a large file line by line in constant memory
processLargeFile :: FilePath -> IO ()
processLargeFile path =
  runConduitRes $
       sourceFile path
    .| decodeUtf8C
    .| linesUnboundedC
    .| filterC (T.isPrefixOf "ERROR")
    .| mapC (T.append "[FILTERED] ")
    .| encodeUtf8C
    .| stdoutC

-- Custom source: produce elements
infiniteSource :: Monad m => ConduitT () Int m ()
infiniteSource = go 0
  where go n = do
          yield n
          go (n + 1)

-- Pipeline with transformation and collection
example :: IO [Int]
example =
  runConduit $
       infiniteSource
    .| takeC 10
    .| mapC (*2)
    .| sinkList
-- [0,2,4,6,8,10,12,14,16,18]

Conduit handles resource acquisition and release correctly even in the presence of exceptions. A file opened with sourceFile is guaranteed to be closed when the pipeline terminates, whether normally or by exception.

STM: Software Transactional Memory

STM is Haskell’s answer to concurrent mutable state. Instead of locks (which compose badly) or single-threaded actors (which are slow), STM lets you write transactions that execute atomically:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
import Control.Concurrent.STM
import Control.Concurrent

-- TVar: a transactional variable
type Account = TVar Int

newAccount :: Int -> IO Account
newAccount = newTVarIO

-- Transfer is an atomic transaction — either both happen or neither does
transfer :: Account -> Account -> Int -> IO ()
transfer from to amount = atomically $ do
  fromBalance <- readTVar from
  when (fromBalance < amount) $ retry  -- retry if insufficient funds
  modifyTVar' from (subtract amount)
  modifyTVar' to   (+ amount)

-- retry blocks the transaction and retries when any TVar it read changes
-- This means the transaction automatically re-runs when the balance changes

-- Composing transactions with orElse
withdrawOrWait :: Account -> Int -> STM Int
withdrawOrWait account amount = do
  balance <- readTVar account
  if balance >= amount
    then do modifyTVar' account (subtract amount)
            return amount
    else retry

-- Two concurrent transactions that don't interfere with each other
example :: IO ()
example = do
  a1 <- newAccount 1000
  a2 <- newAccount 500
  concurrently_
    (transfer a1 a2 200)
    (transfer a2 a1 100)

STM transactions compose: you can build complex transactions from simple ones using do notation, and the whole thing executes atomically. If two transactions conflict (both tried to modify the same TVar), one retries. This is the key advantage over locks: lock-based code does not compose — you cannot combine two correctly locked operations into a larger correctly locked operation without reasoning about the entire lock hierarchy.


GHC Extensions Worth Knowing

GHC extensions are opt-in language features beyond the Haskell standard. They range from essential to dangerous. The ones worth knowing:

Everyday Extensions

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
{-# LANGUAGE OverloadedStrings #-}
-- String literals can be used as Text, ByteString, or any IsString instance
-- Without this, you need explicit T.pack "foo" everywhere
name :: Text
name = "Alice"  -- Works without fromString

{-# LANGUAGE RecordWildCards #-}
-- Brings all record fields into scope
formatUser :: User -> String
formatUser User{..} = userName ++ " <" ++ userEmail ++ ">"
-- Instead of: \u -> userName u ++ " <" ++ userEmail u ++ ">"

{-# LANGUAGE LambdaCase #-}
-- Shorthand for \x -> case x of
describeList :: [a] -> String
describeList = \case
  []  -> "empty"
  [_] -> "singleton"
  _   -> "longer"

{-# LANGUAGE TupleSections #-}
-- Partial tuple application
tagWithIndex :: [a] -> [(Int, a)]
tagWithIndex = zipWith (,) [0..]
-- (,) 5 is equivalent to \x -> (5, x)

Type System Extensions

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
{-# LANGUAGE ScopedTypeVariables #-}
-- Type variables in forall scopes are accessible throughout the function body
readAndParse :: forall a. (Read a) => IO a
readAndParse = do
  line <- getLine
  case reads line of
    [(x, "")] -> return x
    _         -> fail ("Could not parse as " ++ show (typeRep (Proxy :: Proxy a)))

{-# LANGUAGE TypeApplications #-}
-- Explicitly provide type arguments at call sites
parsedInt    = read @Int    "42"
parsedDouble = read @Double "3.14"
parsedBool   = read @Bool   "True"

-- Very useful with functions that have ambiguous types
showJSON = show (fromJSON @User someValue)

{-# LANGUAGE DerivingVia #-}
-- Derive instances by specifying an implementation type
newtype MyList a = MyList [a]
  deriving (Show, Eq)
  deriving (Functor, Foldable) via []  -- Use []'s implementations

A Note on Extension Proliferation

The ecosystem has converged on a set of extensions that are nearly universally enabled. The GHC2021 language edition bundles the stable, widely-used ones. Modern Haskell projects typically start with GHC2021 and add specific extensions as needed rather than listing dozens of pragmas.


Tooling

GHCup

ghcup is the universal installer for the Haskell toolchain. Do not use your Linux distribution’s package manager for GHC — versions go stale quickly and the ecosystem depends on specific GHC versions.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# Install ghcup
curl --proto '=https' --tlsv1.2 -sSf https://get-ghcup.haskell.org | sh

# Install a specific GHC version
ghcup install ghc 9.8.1

# Set the default
ghcup set ghc 9.8.1

# Install all tooling at once
ghcup install ghc
ghcup install cabal
ghcup install hls
ghcup install stack

Cabal vs Stack

Both are build tools and package managers, but they make different tradeoffs.

Cabal is the standard Haskell build tool, part of GHC. Modern Cabal (3.x) with the v2-build interface is excellent. It uses Hackage (the Haskell package repository) directly and resolves dependencies with a SAT solver.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# Start a new project
cabal init myproject

# Build
cabal build

# Run tests
cabal test

# Run a REPL with all project dependencies available
cabal repl

# Execute a binary
cabal run myexecutable -- --arg1 value1

Stack builds on top of Cabal but uses curated Stackage snapshots — sets of packages that are known to compile together at specific versions. This makes reproducible builds trivial at the cost of flexibility.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# Initialize with a Stackage snapshot
stack new myproject simple

# Build
stack build

# REPL
stack ghci

# Run
stack exec myexecutable -- --args

The practical difference in 2026: if you are starting a new project, Cabal with cabal.project and a frozen plan is roughly equivalent to Stack in reproducibility. Stack still wins for teams that want opinionated reproducibility without thinking about it. Cabal wins for flexibility and for avoiding the indirection layer.

HLS: Haskell Language Server

HLS provides IDE support: type-on-hover, jump-to-definition, inline error messages, code actions, and completions. Configure your editor to use it.

1
2
3
4
5
6
# Install via ghcup (recommended)
ghcup install hls

# For VS Code: install the "Haskell" extension, it auto-discovers HLS
# For Neovim: use nvim-lspconfig with haskell-tools.nvim
# For Emacs: use lsp-mode or eglot

For VS Code, the haskell.haskell extension with HLS gives you an experience roughly comparable to TypeScript development — inline types, refactoring, and immediate error feedback.

ghcid for Fast Feedback

ghcid wraps GHC’s interactive mode to give sub-second feedback on changes — faster than a full cabal build cycle:

1
2
3
4
5
6
7
8
# Install
cabal install ghcid

# Run in project directory
ghcid

# With a specific command
ghcid --command="cabal repl" --test=":main"

ghcid keeps GHC loaded in memory. Changes to any file trigger an incremental recompile of only the affected modules. For a medium-sized project, this means feedback in under a second versus the tens of seconds a cold build takes.

Profiling

GHC’s profiling support is mature. Time and allocation profiling requires a profiling build:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
# Build with profiling
cabal build --enable-profiling

# Run with time profiling
./myprogram +RTS -p -RTS

# Generates myprogram.prof:
# COST CENTRE  MODULE      %time  %alloc
# computation  MyModule    42.3   31.1
# ...

# Heap profiling
./myprogram +RTS -hc -RTS
hp2ps myprogram.hp
# Generates a PostScript graph of heap allocation over time

# Eventlog for threadscope (concurrent programs)
./myprogram +RTS -l -RTS
threadscope myprogram.eventlog

For allocation profiling, the -prof -fprof-auto flags annotate every function with a cost centre. For production profiling, use -fprof-late which annotates only top-level definitions and has lower overhead.


Where Haskell Is Actually Used

Finance

Haskell’s adoption in finance is the most documented example of the language working in production at scale. The type safety argument resonates with people who have seen what happens when a trading system has a subtle bug.

Standard Chartered built Mu, a domain-specific language for pricing financial derivatives, in Haskell. The language’s expressiveness for DSL construction and its type safety for financial calculations made it the right choice. A pricing error that compiles is a pricing error that can be detected at type check time, not discovered after losing money.

Mercury Bank (banking infrastructure for startups) runs its backend on Haskell. They have written publicly about the tradeoffs — the type system catches whole classes of bugs before deployment, but hiring is genuinely harder and the learning curve for new engineers is steep.

Tsuru Capital and Barclays have used Haskell in their infrastructure. The pattern is consistent: domains where correctness has a direct financial cost, where the engineering team can afford to hire Haskell developers, and where the long-term maintenance benefits outweigh the short-term hiring friction.

Compilers and Language Tools

Haskell is exceptionally good at writing languages. The combination of algebraic data types (ideal for ASTs), pattern matching, type classes, and parser combinator libraries makes compiler development in Haskell significantly more concise than in C++ or Java.

GHC (the Glasgow Haskell Compiler) is itself written in Haskell. This is not merely a point of pride — it means the toolchain that processes Haskell code is a Haskell program, bootstrapped via a bootstrapping GHC binary.

Pandoc, John MacFarlane’s universal document converter, is written in Haskell. It converts between dozens of document formats (Markdown, LaTeX, HTML, EPUB, RST, and more). The algebraic data type representing Pandoc’s internal document model is the central abstraction that makes multi-direction conversion possible.

ShellCheck, the shell script static analyzer, is written in Haskell. It statically analyzes shell scripts for common bugs, style issues, and portability problems — and it does this with enough precision to suggest specific fixes.

Dhall, a functional configuration language designed to be total (no infinite loops, no crashes), is implemented in Haskell. Its strong normalization guarantees are enforced by the type system.

Facebook’s Sigma

Facebook built Sigma, their anti-abuse and spam filtering system, using Haskell via the Haxl framework (open-sourced). Haxl is a library for efficiently executing concurrent data fetches — it automatically batches and deduplicates requests to data sources. The pure functional model made it straightforward to parallelize fetches that had no data dependencies.

At the scale Facebook operates, the combination of correctness guarantees and the ability to safely reason about concurrent data access was worth the language investment.

Academic and Research Use

Haskell is the language of choice for programming language theory research. Papers about type systems, effect systems, generic programming, and category theory frequently come with Haskell implementations. If you want to implement a paper about dependent types, row polymorphism, or algebraic effects, Haskell gives you the tools to do it.

Agda and Idris, dependently typed languages that push type-level programming further than Haskell, are both written in Haskell. The compiler infrastructure, metaprogramming support, and algebraic data types make Haskell the natural choice for bootstrapping a new language.


Honest Assessment

The Learning Curve Is Real

Learning Haskell is not like learning your third imperative language. The concepts — type classes, monads, lazy evaluation, functor hierarchies — require building new mental models, not extending existing ones. A programmer with ten years of Java experience starts Haskell as a beginner in a meaningful sense.

The first few weeks feel like walking through mud. Type errors are informative but verbose. The monad stack is confusing. The indirection through type classes obscures what the program is actually doing. The laziness model means space behavior is harder to reason about than in strict languages.

Most programmers who work through this report a permanent change in how they think about code — not just in Haskell, but in whatever language they return to. The concepts are transferable: algebraic data types are available in Rust, Scala, Swift, and Kotlin; monadic interfaces show up in Go’s context package, Kotlin’s coroutines, and JavaScript’s Promises (imperfectly); property testing (QuickCheck) has been ported to dozens of languages.

The question is whether you can afford the learning curve given your team’s constraints.

Hiring and Team Constraints

The pool of experienced Haskell engineers is small. Unlike Rust (which has been growing rapidly due to Linux kernel and system programming adoption) or TypeScript (everywhere), Haskell expertise is concentrated in specific communities: academia, finance, and companies that made a deliberate bet on the language years ago.

This matters for three reasons:

  1. Onboarding: A new hire who is an experienced Python or Java engineer will not be productive in Haskell immediately. The ramp-up is longer than in most languages.
  2. Hiring volume: You cannot hire for Haskell at scale. If your team grows from 5 to 50 engineers, you will either need to train most of them or accept that your Haskell codebase becomes a maintenance burden for engineers who are not fluent.
  3. Bus factor: A system written in Haskell by two fluent engineers and then handed to a team of six Python engineers is a system that will be rewritten.

If you are a startup evaluating Haskell as your primary language: think hard about this. The type safety wins are real, but they compound over years. The hiring constraint hits in months.

Where Haskell Genuinely Wins

Correctness-critical code: Anywhere a subtle bug has outsized consequences — financial systems, compiler infrastructure, configuration management systems, data pipeline transformations that need to be exactly right. The type system pays back its cost here.

DSL implementation: Haskell is perhaps the best language for building domain-specific languages. Parser combinator libraries (Parsec, Megaparsec), Template Haskell for compile-time code generation, and the algebraic data types for AST representation give you a complete toolkit.

Compiler infrastructure: If you are building a compiler, transpiler, or code analysis tool, Haskell gives you advantages that few other languages match. GHC’s core is a good existence proof.

Long-lived codebases with small teams: A small team with high Haskell fluency can maintain a large Haskell codebase more safely than the equivalent in a dynamic language, because the type system continues to enforce invariants as the code grows.

Research implementation: If you need to implement a type theory paper, a new query language, or a novel concurrency primitive, Haskell is where ideas come with the least friction.

Where Haskell Loses

Machine learning: The Python ecosystem for ML (PyTorch, TensorFlow, JAX, Hugging Face) has no meaningful competition. The Haskell ML ecosystem is thin and immature. If your workload is training neural networks, Haskell is not the answer.

Web frontends: Haskell compiles to JavaScript via GHCJS or Miso, but the ecosystem is far behind TypeScript/React. The tooling, component libraries, and community support are not comparable.

Rapid prototyping: The compilation step and type system enforcement that help in production hurt in exploration. Python is faster to sketch an idea in. Haskell rewards investment in the idea; Python rewards iteration.

Hiring at scale: Covered above. If you need to hire 20 engineers this year, Haskell is probably not the answer.

Comparison: Scala, OCaml, Rust

These are the most common alternatives when “typed functional programming” is the requirement.

Scala runs on the JVM (and transpiles to JavaScript via Scala.js), which means JVM interop is free, the hiring pool is larger (especially in data engineering, where Spark is Scala), and the ecosystem is vast. The downside: Scala’s type system is powerful but inconsistent; compilation is slow; the language has multiple competing paradigms (OOP from Java, functional from Haskell, actor-based from Akka). If JVM ecosystem access matters, Scala is the pragmatic typed FP choice. Cats Effect and ZIO have made Scala’s FP story much stronger in the 2020s.

OCaml occupies a similar niche to Haskell but with strict evaluation (no lazy by default), a simpler type system (powerful but without type classes in the Haskell sense — though OCaml 5’s modular implicits are changing this), and better performance predictability. Jane Street’s heavy use of OCaml (they employ many core OCaml contributors) has produced an excellent tooling ecosystem (opam, dune, Base, Core). If you want typed FP without the laziness mental model and with better runtime performance predictability, OCaml is worth evaluating.

Rust is not a functional language, but it occupies adjacent space in the “strong types, correctness guarantees” discussion. Rust has sum types, pattern matching, traits (similar to type classes), and an excellent type system. It lacks higher-kinded types and monadic composition in the same form, but the async/await ecosystem, the Iterator trait, and the Option/Result types cover the most common FP patterns. Rust’s hiring pool is much larger than Haskell’s and growing. If your primary motivation for Haskell is correctness and type safety rather than category-theory-style abstraction, Rust might serve you better.


A Practical Starting Point

If you want to actually learn Haskell rather than just read about it:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
-- Start with this: implement a small interpreter for a toy language.
-- It exercises everything: ADTs for the AST, pattern matching for eval,
-- Maybe/Either for errors, State for environment, IO for the REPL.

data Expr
  = Num Double
  | Var String
  | Add Expr Expr
  | Mul Expr Expr
  | Let String Expr Expr  -- let x = e1 in e2

type Env = Map String Double

eval :: Env -> Expr -> Either String Double
eval _   (Num n)     = Right n
eval env (Var x)     = maybe (Left ("unbound: " ++ x)) Right (Map.lookup x env)
eval env (Add e1 e2) = (+) <$> eval env e1 <*> eval env e2
eval env (Mul e1 e2) = (*) <$> eval env e1 <*> eval env e2
eval env (Let x e body) = do
  val <- eval env e
  eval (Map.insert x val env) body

-- This single function exercises: pattern matching on ADTs, Map lookup,
-- Maybe->Either conversion, Applicative style (<$>, <*>), do notation on Either

repl :: IO ()
repl = go Map.empty
  where
    go env = do
      putStr "> "
      hFlush stdout
      line <- getLine
      case parseLine line of
        Left err   -> putStrLn ("Parse error: " ++ err) >> go env
        Right expr -> case eval env expr of
          Left err  -> putStrLn ("Error: " ++ err) >> go env
          Right val -> print val >> go env

The resources that have worked for people:

  • “Programming in Haskell” by Graham Hutton — concise, mathematically rigorous, actually explains why things are the way they are
  • “Haskell Programming from First Principles” (haskellbook.com) — exhaustive, exercise-heavy, takes you from nothing to production
  • The Haskell wiki and Wikibookslearnyouahaskell.com is dated but still serviceable for initial concepts
  • Real projects: after the basics, contributing to a Haskell project on GitHub or building something with Servant and Aeson teaches more than any book

The Haskell community hangs out on the Haskell Discord, the Haskell Discourse forum, and the #haskell IRC channel on Libera.Chat. The community skews academic but is generally patient with serious learners.


The Compounding Returns Argument

The best argument for learning Haskell is not that you will use it in production (you might not). It is that learning it changes how you think about every other language you use.

After Haskell, you write better Rust because you understand why ownership works the way it does and how to use Option and Result idiomatically. You write better TypeScript because you model data with discriminated unions instead of sprawling class hierarchies. You write better Python because you separate pure computation from I/O naturally and reach for dataclasses and TypedDict instead of unstructured dicts. You design better APIs because you think about what errors should be expressed in the type versus raised as exceptions.

The ideas that Haskell makes concrete — algebraic data types, parametric polymorphism, effect tracking, totality — are increasingly mainstream. They just come with training wheels in other languages. Learning Haskell is like removing the training wheels and understanding why bicycles work.

Whether that understanding is worth the investment depends on your goals. If you are building ML pipelines, hire Python engineers. If you are building a trading system or a compiler, consider Haskell seriously. If you want to understand type systems deeply enough to contribute to a typed language’s design, Haskell is essentially the prerequisite.

The language rewards patience and punishes shortcuts. That is not for everyone. But the engineers who work through it tend to regard it as one of the most intellectually significant things they have done — not because Haskell is better in some absolute sense, but because it forces you to reckon with ideas that most programming careers let you ignore.

Comments