Haskell Interview Questions and Answers

Last updated:

Check out 40 of the most common Haskell interview questions, then take an AI-powered practice interview

Functional ProgrammingType TheoryCategory TheoryMonadsCompiler Design
40+
Questions
14
Basic
16
Intermediate
10
Advanced
Q1

What is Haskell and what makes it different from mainstream languages?

BasicFundamentals

Answer

Haskell is a purely functional, statically-typed, lazily-evaluated programming language first standardised in 1990 and now maintained as the Haskell 2010 standard (with GHC providing the de-facto modern dialect). Three properties make it different from mainstream languages like Python or Java: (1) Purity, functions cannot have side effects unless their type says so (every IO action is reflected in its type as `IO a`), (2) Laziness, expressions are not evaluated until their value is needed, which enables infinite data structures and powerful compositional patterns, and (3) Hindley-Milner type inference, the compiler infers most types for you, but the type system is strict enough to eliminate whole classes of bugs (null pointer exceptions are impossible in idiomatic Haskell). In production, this means Haskell programs tend to have fewer runtime bugs once they compile, at the cost of a steeper learning curve.

Real-world deployments include the Cardano blockchain (entire ledger in Haskell), Standard Chartered's derivatives pricing engine, Facebook's Sigma anti-spam system, and GitHub's Semantic code-analysis library. The language is small (~50 keywords) but the type system is vast, which is why most Haskell learning curve is really 'learning the type-level vocabulary' rather than syntax.

Key Points

  • Pure functional, side effects encoded in types (IO monad)
  • Lazy evaluation by default
  • Hindley-Milner static type inference
  • GHC 9.10 is the standard compiler in 2026
Q2

How do you define and call a function in Haskell?

BasicSyntax

Answer

Functions in Haskell are defined by writing the function name, parameters separated by spaces, an equals sign, and the body. Type signatures are optional (the compiler infers them) but recommended for top-level definitions. Function application is by juxtaposition, no parentheses needed, and it associates to the left, so `f x y` means `(f x) y`.

Every function is curried by default: `add :: Int -> Int -> Int` is actually `Int -> (Int -> Int)`, so partial application is free. Application binds tighter than every operator, so `f x + 1` parses as `(f x) + 1`, and `f (x + 1)` genuinely needs the parentheses. That precedence rule is why `$` exists: it is an ordinary operator declared `infixr 0`, so `print $ sum xs` means `print (sum xs)`.

Any two-argument function can be written infix by surrounding it with backticks, as in 10 `div` 3, and any operator can be partially applied as a section: `(2^)`, `(^2)`, `(subtract 1)`. Note `(-1)` is negative one, not a section, which is a classic parse trap. One definition can have several equations with different patterns, guards, and a `where` block; the equations are tried top to bottom.

Two follow-ups come up constantly in interviews. First, write the function point-free: `add = (+)`, or for something like `count = length . filter even`, and explain when point-free stops being readable. Second, explain why a top-level binding written without a type signature sometimes gets a narrower type than expected, for example `x = 3` defaulting to `Integer` rather than staying `Num a => a`. That is the monomorphism restriction; the fix is to write the signature rather than to enable `NoMonomorphismRestriction`.

-- Type signature is optional but idiomatic
add :: Int -> Int -> Int
add x y = x + y

-- Calling: no parens, just spaces
result = add 3 4   -- 7

-- Partial application: add5 is a function Int -> Int
add5 :: Int -> Int
add5 = add 5

Key Points

  • No parens around arguments
  • All functions are curried
  • Type signatures use `::` and `->`
Q3

What is lazy evaluation in Haskell?

BasicEvaluation

Answer

Lazy (non-strict) evaluation means an expression is not evaluated when it's bound, only when its value is actually demanded. Haskell wraps every unevaluated expression in a 'thunk', and the runtime forces thunks on demand. This has three big consequences: (1) you can define infinite data structures like `ones = 1 : ones` and use them as long as you only consume a finite prefix, (2) function arguments aren't computed if the function doesn't use them, `if`/`else` is just a normal function, no special syntax needed, (3) you get short-circuiting for free everywhere.

The flip side is space leaks: thunks pile up in memory if you build large structures without forcing them, which is the #1 production gotcha in Haskell. The precise rule is that forcing a value reduces it to weak head normal form (WHNF), meaning just the outermost constructor, not the whole structure. `length [undefined, undefined]` is 2 because `length` never looks inside the cells, while `sum` would crash. That distinction explains the classic interview question: `foldl (+) 0 [1..10000000]` builds a chain of ten million unevaluated additions and blows the stack when the result is finally demanded, whereas `foldl'` from `Data.List` forces the accumulator at each step and runs in constant space.

Laziness also changes debugging: `Debug.Trace.trace` fires in demand order rather than source order, so output looks reordered, and an exception can surface far from the code that created it. Tools to control it are `seq`, `$!`, `BangPatterns`, `deepseq` (`force`, `NFData`) for full evaluation, and the module-wide `Strict` and `StrictData` pragmas. A senior interviewer usually follows up with 'when does laziness actually help', and the honest answers are early termination over large inputs, knot-tying and self-referential definitions like a lazy Fibonacci list, and treating control structures as ordinary functions.

-- Infinite list, works because of laziness
nats :: [Int]
nats = [0..]

firstTen :: [Int]
firstTen = take 10 nats   -- [0,1,2,3,4,5,6,7,8,9]

-- This is fine, the second arg is never forced
safeHead :: a -> [a] -> a
safeHead def xs = if null xs then def else head xs
Q4

What are algebraic data types (ADTs) in Haskell?

BasicTypes

Answer

ADTs let you define a new type as a combination of sum (or) and product (and) of other types. The `data` keyword introduces them. Sum types (also called tagged unions) enumerate alternatives, `data Maybe a = Nothing | Just a` says a `Maybe a` is either `Nothing` or `Just a`.

Product types bundle multiple fields, `data Point = Point Double Double` is a pair of doubles. Records add field names. ADTs combined with pattern matching are the workhorse of Haskell, most domain modelling uses them instead of class hierarchies.

Crucially, ADTs are sealed by default (no inheritance), which makes pattern matches exhaustive, the compiler warns you if you miss a case (build with `-Wall`, or at minimum `-Wincomplete-patterns`, and treat that warning as an error in CI). Three details separate a junior answer from a senior one. First, `newtype` is a one-constructor, one-field special case that is erased at runtime, so `newtype UserId = UserId Int` costs nothing and still stops you passing an `OrderId` where a `UserId` belongs.

Second, constructor fields are lazy by default, so `data Stats = Stats Int Int` can hold thunks; write `!Int` or turn on `StrictData` for accumulator-shaped records. Third, record syntax generates partial field selectors when a field is not present in every constructor, and calling one on the wrong constructor throws 'No match in record selector' at runtime; `-Wpartial-fields` catches it, and `NoFieldSelectors` plus `OverloadedRecordDot` (GHC 9.2 and later) is the modern way to avoid the whole category. The design principle interviewers are listening for is 'make illegal states unrepresentable': model a payment as `data Payment = Pending Amount | Settled Amount UTCTime | Failed Reason` instead of one record with nullable fields and a status string.

-- Sum type: Shape is either a Circle or a Rectangle
data Shape
  = Circle Double
  | Rectangle Double Double
  deriving (Show, Eq)

-- Product type with record syntax
data Person = Person
  { name :: String
  , age  :: Int
  } deriving (Show)

-- Pattern match, compiler warns if you forget a constructor
area :: Shape -> Double
area (Circle r)      = pi * r * r
area (Rectangle w h) = w * h
Q5

What is pattern matching in Haskell?

BasicSyntax

Answer

Pattern matching is how you destructure values and dispatch on their shape. You can match on literals, constructors, tuples, lists, and bind variables in one go. It happens in function definitions (multiple clauses), `case` expressions, and `let`/`where` bindings.

Patterns are tried top-to-bottom, and an underscore `_` is a wildcard. The compiler checks exhaustiveness for ADTs, if you compile with `-Wall`, you get warnings for any unmatched constructor, which catches a lot of bugs at compile time. Guards (`|`) and view patterns extend the basic mechanism: guards let you add Boolean conditions to a clause, while view patterns (a GHC extension) let you match the result of an arbitrary function.

The `LambdaCase` extension is one of the most popular GHC extensions because it lets you write a case-style function without naming its argument, and GHC 9.4 extended it with `\cases` for multiple arguments at once. Beyond the basics, four forms show up in real code. As-patterns bind the whole value while also destructuring it: `f all@(x:xs)` gives you `all` without rebuilding `x:xs`.

Irrefutable patterns written with a tilde, as in `f ~(a, b)`, delay the match until the components are demanded, which matters for lazy recursive definitions. Bang patterns force a value at match time and are the usual fix for a leaking fold accumulator. Pattern synonyms (`PatternSynonyms`) let a library expose a stable pattern surface over a changing internal representation.

The failure modes matter in production: a missed constructor gives 'Non-exhaustive patterns in function f' with a source location at runtime, and a failed pattern bind in a `do` block calls `fail` from `MonadFail`, which is `Nothing` in `Maybe` but a thrown `IOException` in `IO`. Turn on `-Wincomplete-uni-patterns` as well as `-Wincomplete-patterns`, because the first catches the `let Just x = lookup k m` style that the second ignores.

-- Multiple clauses with different patterns
length' :: [a] -> Int
length' []     = 0
length' (_:xs) = 1 + length' xs

-- Case expression
classify :: Int -> String
classify n = case n of
  0          -> "zero"
  n | n > 0  -> "positive"
  _          -> "negative"
Q6

What is the difference between `let` and `where` in Haskell?

BasicSyntax

Answer

Both introduce local bindings, but they differ in scope and ergonomics. `let` is an expression, it can appear anywhere a value is expected, and the bindings are visible only within the `in` clause. `where` is attached to a function clause, and its bindings are visible across all guards of that clause. Idiomatically, `where` is preferred for top-level helpers within a function (reads top-down), and `let` is preferred for short local values inside a `do` block or expression. The compiler treats them similarly; the choice is mostly stylistic.

The real differences are scope and sharing. A `where` block scopes over every guard of a single equation but not across equations, so two clauses of the same function cannot share one `where`. A `let` inside a `do` block drops the `in` entirely (`let n = length xs` on its own line) and stays in scope for the rest of the block, while `let ... in ...` in an expression does not.

Both are recursive and mutually recursive by default, so `let xs = 1 : xs` is a valid infinite list rather than a scope error. Two practical gotchas come up in review. A binding that does not mention any function argument is still recomputed on each call, but if you lift it to the top level it becomes a CAF (constant applicative form) that GHC evaluates once and keeps alive for the process lifetime, which is a caching win for a lookup table and a memory leak for a large list.

And pattern bindings such as `let (a, b) = f x` are subject to the monomorphism restriction, so if the inferred type looks unexpectedly concrete, add a signature inside the `where` block. Enable `-Wunused-local-binds` to catch helpers left behind after a refactor.

-- where: bindings visible across guards
bmiTell :: Double -> Double -> String
bmiTell weight height
  | bmi < 18.5 = "Underweight"
  | bmi < 25.0 = "Normal"
  | otherwise  = "Overweight"
  where bmi = weight / height^2

-- let: an expression, visible only in `in`
cylinder :: Double -> Double -> Double
cylinder r h =
  let sideArea = 2 * pi * r * h
      topArea  = pi * r^2
  in sideArea + 2 * topArea
Q7

What are list comprehensions and how do they work?

BasicLists

Answer

List comprehensions are syntactic sugar for building lists from generators and predicates, modelled on mathematical set-builder notation. The form is `[expr | generator, predicate, ...]`. Multiple generators behave like nested loops (rightmost iterates fastest), and predicates filter values.

They desugar to a chain of `concatMap` and `filter` calls. Useful for quick data transformations but for complex pipelines, idiomatic Haskell often prefers explicit `map`/`filter`/`>>=` or the `do` notation on lists. Three things are worth knowing beyond the syntax.

Generator order defines the nesting, so `[(a,b) | a <- [1..], b <- [1..]]` never produces a pair with `a = 2`: the innermost generator is infinite and starves the outer one. Diagonalising by hand, or using a fair interleaving, is the fix. You can also bind intermediate values with `let` inside the comprehension (no `in`), and a pattern that fails to match is silently skipped, so `[x | Just x <- xs]` is the idiomatic way to drop `Nothing`s from a `[Maybe a]`.

On performance, GHC desugars comprehensions to `build`/`foldr` form, so with `-O2` the foldr/build fusion rules usually eliminate the intermediate list entirely and `sum [x*x | x <- [1..n], even x]` compiles to a tight loop with no allocation; you can confirm that with `-ddump-simpl`. GHC extensions widen the syntax further: `MonadComprehensions` generalises it to any monad, `ParallelListComp` allows zip-style bars, and `TransformListComp` adds SQL-like `group by` and `sortWith`. For anything keyed or set-shaped, build a `Data.Map` from the comprehension rather than scanning a list repeatedly.

-- Pythagorean triples with sides under 20
triples :: [(Int, Int, Int)]
triples = [(a,b,c) | c <- [1..20], b <- [1..c], a <- [1..b], a^2 + b^2 == c^2]

-- Filter and transform in one go
evenSquares :: [Int]
evenSquares = [x^2 | x <- [1..10], even x]   -- [4,16,36,64,100]
Q8

What is a type class in Haskell?

BasicType Classes

Answer

A type class is an interface, a contract that types can implement. `class Eq a where (==) :: a -> a -> Bool` declares a type class `Eq` with one method. Then `instance Eq Bool where True == True = True; False == False = True; _ == _ = False` provides an implementation. Functions can take type-class constraints: `elem :: Eq a => a -> [a] -> Bool` means 'works on any `a` that has an `Eq` instance'.

Built-in classes like `Eq`, `Ord`, `Show`, `Read`, and `Functor` are everywhere. Type classes are how Haskell achieves ad-hoc polymorphism, similar to interfaces in Java or traits in Rust, but resolved at compile time via dictionary passing. That implementation detail matters: GHC turns a constraint like `Eq a =>` into an extra hidden argument holding a record of methods, which is why constrained functions can be slower in a hot loop and why `{-# SPECIALISE #-}` or `INLINABLE` helps.

Four things distinguish a strong answer. Classes can have superclasses (`class Eq a => Ord a`), so an `Ord` instance requires an `Eq` instance. They can have default methods plus a `{-# MINIMAL #-}` pragma stating which methods you must define, which is why you only implement `compare` or `<=` for `Ord`.

Instance resolution ignores return-type ambiguity poorly, so `read "42"` alone fails with 'Ambiguous type variable a0 arising from a use of read'; fix it with `read @Int "42"` using `TypeApplications` or an annotation. And unlike Java interfaces, instances are global: there can be exactly one `Eq Foo` in the whole program, so defining an instance in a module that owns neither the class nor the type creates an orphan instance, which `-Worphans` flags and which breaks silently when two libraries disagree. The standard workaround is a `newtype` wrapper, which is exactly how `Sum` and `Product` give `Int` two different `Monoid` instances.

class Greet a where
  greet :: a -> String

data Hindi = Hindi
data English = English

instance Greet Hindi where
  greet _ = "Namaste"

instance Greet English where
  greet _ = "Hello"

-- greet Hindi   -> "Namaste"
-- greet English -> "Hello"
Q9

What is immutability and how does Haskell enforce it?

BasicFundamentals

Answer

In Haskell, values are immutable by default, once `x = 5`, you cannot make `x` mean anything else later in the same scope. There is no mutable assignment operator in the core language. 'Mutation' is achieved by creating new values that incorporate the change (e.g. `map (+1) xs` returns a new list rather than mutating `xs`).

This sounds expensive but laziness + sharing means most updates are cheap, under the hood, the runtime shares unchanged structure between the old and new values. When real mutation is needed (performance, IO), Haskell offers `IORef`, `MVar`, `STRef`, and `TVar`, all of which are explicit and reflected in the type. This makes data races impossible without explicitly opting in to mutable state.

The practical pay-off: in a Haskell codebase, you can reason about almost any function in isolation, and refactoring becomes far safer because no remote caller can mutate the state you depend on. For collections, libraries like `Data.Map`, `Data.IntMap`, `Data.Set`, and `Data.Sequence` provide persistent (immutable but efficiently updatable) data structures with O(log n) operations, so the immutability rarely costs you performance in real applications.

import           Data.IORef
import qualified Data.Map.Strict as Map

-- Pure "update": returns a new Map, shares untouched subtrees
bumpScore :: String -> Map.Map String Int -> Map.Map String Int
bumpScore user = Map.insertWith (+) user 1

-- Real mutation must be declared in the type
counterDemo :: IO Int
counterDemo = do
  ref <- newIORef (0 :: Int)
  modifyIORef' ref (+1)   -- note the prime: strict, no thunk buildup
  readIORef ref
Q10

What is the `IO` monad and why does Haskell need it?

BasicIO

Answer

Because Haskell is pure, a function with type `Int -> Int` cannot print to the console, read a file, or get the current time, a pure function can only compute. The `IO` type tags an action that interacts with the outside world. `getLine :: IO String` is not a `String`; it's a description of an action that, when run, produces a `String`. You sequence IO actions with `do` notation or the `>>=` operator, and the only place IO actually runs is `main :: IO ()`.

This rigorous separation means a function's type tells you whether it can do IO. In production codebases, you keep most logic pure and push IO to the edges (the 'functional core, imperative shell' pattern). A few points separate people who have shipped Haskell from people who have only read about it. `IO` is a first-class value, so you can put actions in a list and run them later with `sequence_` or `traverse_`, and building the list has no effect at all until something runs it.

Evaluation order and execution order are different: forcing `IO` to WHNF does not perform it. Libraries take `MonadIO m => m a` rather than plain `IO a` so callers can run them inside a transformer stack via `liftIO`. `unsafePerformIO` exists, it is used inside `bytestring` and similar libraries, and it is the wrong answer in an interview unless you can name the required `NOINLINE` pragma and the risk of the effect being duplicated or floated out by the optimizer. The most common real production surprise is buffering: GHC gives `stdout` line buffering on a terminal but block buffering when it is a pipe, so a containerised service can appear to log nothing until it crashes. The fix is `hSetBuffering stdout LineBuffering` at the top of `main`.

main :: IO ()
main = do
  putStrLn "Enter your name:"
  name <- getLine
  putStrLn ("Hello, " ++ name ++ "!")
Q11

What is the difference between Stack and Cabal in Haskell?

BasicTooling

Answer

Cabal is the original build system and package format for Haskell, it reads `.cabal` files and downloads dependencies from Hackage. Stack is a wrapper that sits on top of Cabal and adds curated, reproducible package sets (Stackage snapshots), so `stack build` always picks the same dependency versions for a given resolver. In 2026, the divide has narrowed: Cabal now has `cabal-install` with freeze files and v2-build, which gives Stack-like reproducibility.

Choose Stack if your team wants zero-config curated snapshots; choose Cabal if you want fine-grained control over versions or are building libraries that need maximum compatibility. GHCup is the recommended installer for both, and `ghcup tui` is how most teams switch GHC and HLS versions. Concretely: Stack reads `stack.yaml`, where a line like `resolver: lts-23.x` pins the compiler and every package version in that Stackage snapshot, and anything outside the snapshot goes in `extra-deps`.

Cabal reads a `.cabal` file plus an optional `cabal.project`, resolves versions itself against Hackage, and you lock them with `cabal freeze` into `cabal.project.freeze`. Build artifacts land in `.stack-work` for Stack and in `dist-newstyle` plus the shared `~/.cabal/store` for Cabal, which is why Cabal reuses compiled dependencies across projects while Stack keeps them per snapshot. Day-to-day commands map one to one: `stack build` and `cabal build`, `stack test` and `cabal test`, `stack ghci` and `cabal repl`, `stack run` and `cabal run`. The usual failure modes are a dependency bound that no plan satisfies (Cabal prints a long 'Could not resolve dependencies' trace, and `--allow-newer` is the escape hatch), a package missing from your snapshot (Stack tells you to add it to `extra-deps`), and HLS refusing to load a project because it was built with a different GHC than the one on `PATH`.

💡 Pro Tip: New projects in 2026 tend to start with `cabal init` + a `cabal.project.freeze` file rather than Stack, but Stack is still widely used in commercial Haskell shops.
Q12

What is `Maybe` and how does it replace null values?

BasicTypes

Answer

`Maybe a` is the standard ADT for representing 'a value of type `a` that might be missing'. It has two constructors: `Nothing` (no value) and `Just a` (a value). Functions that might fail return `Maybe a` instead of `a`-or-null, making the absence of a value part of the type.

The compiler then forces you to handle the `Nothing` case via pattern match. This eliminates an entire class of bugs (null pointer exceptions) and makes intent visible at every call site. `lookup`, `Map.lookup`, and `Data.List.find` all return `Maybe`. For chaining several possibly-failing operations, the `Maybe` monad lets you write `do`-notation that short-circuits on the first `Nothing`. `Data.Maybe` gives you the tools you actually reach for: `fromMaybe def m` to supply a default, `maybe def f m` to fold both cases in one expression, `mapMaybe` to filter and transform a list in one pass, `catMaybes` to drop the misses, and `isJust`/`isNothing` for predicates. `fromJust` is the one to avoid, since it throws 'Maybe.fromJust: Nothing' with no context about which lookup failed.

The `Alternative` instance gives `<|>` for 'first success wins', which is how you write a config fallback chain like `envVar <|> configFile <|> pure defaultValue`. Interviewers usually push on where `Maybe` is the wrong tool: it records that something failed but not why, so anything a user or an on-call engineer has to act on should be `Either MyError a` or a custom sum type instead. In a monad transformer stack, `MaybeT` gives the same short-circuiting over `IO`. One subtlety worth knowing for JSON work: in aeson, `.:?` yields `Nothing` for an absent key while `.:` fails the parse, so modelling an optional field as `Maybe` changes the parser's behaviour, not just the record's shape.

import qualified Data.Map.Strict as Map

lookupName :: Int -> Map.Map Int String -> String
lookupName uid db = case Map.lookup uid db of
  Just name -> name
  Nothing   -> "unknown user"
Q13

What is the difference between `String`, `Text`, and `ByteString`?

BasicTypes

Answer

`String` is a type synonym for `[Char]`, a lazy linked list of characters. Every character is a separate heap cell with a pointer, so a short word costs an order of magnitude more memory than the same word as packed bytes, and `++` is O(n) in the left argument. It is fine for error messages and command-line output, and wrong for anything hot. `Data.Text` from the `text` package is a packed Unicode string; since text-2.0 the internal encoding is UTF-8 rather than UTF-16, which cut memory roughly in half for Latin text and removed most conversion cost at the ByteString boundary. `Data.ByteString` is a packed array of `Word8` with no character semantics at all: it is what you use for network payloads, file contents, and anything you hand to a C library.

The rule in production code is `Text` for human-readable text, `ByteString` for bytes, `String` only at the edges. Convert with `Data.Text.Encoding.encodeUtf8` and `decodeUtf8'` (the primed version returns `Either UnicodeException` instead of throwing). Two traps interviewers like: `Data.ByteString.Char8.pack` silently truncates every character to eight bits, so it mangles any non-ASCII input, and repeated `Text` concatenation should go through `Data.Text.Lazy.Builder` rather than `<>` in a loop. `OverloadedStrings` lets a literal serve as any of the three.

{-# LANGUAGE OverloadedStrings #-}
import qualified Data.Text               as T
import qualified Data.Text.Encoding      as TE
import qualified Data.Text.IO            as TIO
import qualified Data.ByteString         as BS

greeting :: T.Text
greeting = "नमस्ते"            -- literal is Text thanks to OverloadedStrings

asBytes :: BS.ByteString
asBytes = TE.encodeUtf8 greeting

-- Total decode: never throws, unlike decodeUtf8
fromBytes :: BS.ByteString -> Either String T.Text
fromBytes bs = either (Left . show) Right (TE.decodeUtf8' bs)

main :: IO ()
main = TIO.putStrLn (T.toUpper greeting)

Key Points

  • String = [Char], one heap cell per character
  • Text = packed Unicode, UTF-8 internally since text-2.0
  • ByteString = raw Word8, no character semantics
  • decodeUtf8' is total, decodeUtf8 throws
Q14

What do the `$`, `.`, and `&` operators do in Haskell?

BasicSyntax

Answer

They are ordinary functions, not syntax, which is the point of the question. `($) :: (a -> b) -> a -> b` is plain application declared `infixr 0`, the lowest precedence in the language, so it acts as a right-hand parenthesis that runs to the end of the expression: `print $ take 5 $ filter even xs`. `(.) :: (b -> c) -> (a -> b) -> a -> c` is composition, `infixr 9`, and builds a new function instead of applying one: `sumEvens = sum . filter even`. Mixing them up produces the most common beginner type error, since `f . g x` composes `f` with the result of `g x`. `(&)` from `Data.Function` is reverse application, `infixl 1`, so data flows left to right like a pipeline: `xs & filter even & map (*2) & sum`. Related operators worth naming: `<$>` is `fmap` as an operator, `<*>` is applicative application, `<&>` is `&` lifted into a functor, and `$!` forces its argument to WHNF before applying. Two modern notes: the `BlockArguments` extension removes the need for `$` before a `do` block or lambda, and GHC 9.0's simplified subsumption made some eta-reduced point-free code stop compiling, which is why older projects sometimes enable `DeepSubsumption`.

import Data.Function ((&))
import Data.Functor   ((<&>))

-- $ avoids trailing parentheses
showTotal :: [Int] -> IO ()
showTotal xs = print $ sum $ map (*2) $ filter even xs

-- . builds a function, no argument mentioned
sumEvenDoubles :: [Int] -> Int
sumEvenDoubles = sum . map (*2) . filter even

-- & reads left to right
sumEvenDoubles' :: [Int] -> Int
sumEvenDoubles' xs = xs & filter even & map (*2) & sum

-- <$> and <&> in IO
lineLength :: IO Int
lineLength = getLine <&> length
Q15

What is a monad and how is it useful?

IntermediateMonads

Answer

A monad is a type class with two operations: `return :: a -> m a` (lift a value into the monadic context) and `>>= :: m a -> (a -> m b) -> m b` (chain a computation). The point of monads is composing computations that share some 'extra structure', sequencing IO actions, threading state, accumulating logs, handling errors, doing non-determinism, without that structure leaking into every function signature. The classic intuition: a monad is a 'programmable semicolon'.

The `do` notation is syntactic sugar over `>>=`, which is why `do { x <- foo; bar x }` looks imperative but desugars to a pure expression. Common monads: `Maybe` (failure), `Either e` (failure with error), `IO` (effects), `[]` (non-determinism), `State s` (mutable state), `Reader r` (configuration). An interviewer will almost always push further on three points.

First, the laws: left identity (`return a >>= f` equals `f a`), right identity (`m >>= return` equals `m`), and associativity, which is what makes refactoring a `do` block into helper functions safe. An instance that breaks them will typecheck and then behave strangely, so the laws are enforced by tests, not by the compiler. Second, the class hierarchy in modern `base`: `Applicative` is a superclass of `Monad`, `return` is just `pure` and is now redundant, and pattern-match failure moved out into `MonadFail`, which is why `Just x <- action` requires a `MonadFail` instance.

Third, why monads do not compose: given arbitrary `m` and `n` there is no general `Monad (Compose m n)`, and that gap is the entire reason monad transformers exist. Useful extras to mention are `join :: m (m a) -> m a` as the alternative formulation of `>>=`, `>>` and `*>` for sequencing when you discard the result, and `ApplicativeDo`, which lets GHC desugar independent binds to `<*>` so they can run concurrently in monads like Haxl.

-- The Maybe monad short-circuits on the first Nothing
lookupPincode :: String -> Maybe String
lookupPincode city = ...

lookupZone :: String -> Maybe String
lookupZone pincode = ...

deliveryZone :: String -> Maybe String
deliveryZone city = do
  pincode <- lookupPincode city
  zone    <- lookupZone pincode
  return zone

Key Points

  • Two operations: return + >>=
  • do-notation desugars to >>=
  • Common instances: Maybe, Either, IO, [], State, Reader
Q16

How does Hindley-Milner type inference work in Haskell?

IntermediateType System

Answer

Hindley-Milner (HM) is the type inference algorithm at the heart of Haskell. It infers the most general (principal) type of any expression without requiring annotations. The algorithm walks the AST, generates type variables for unknowns, collects equality constraints (`unification`), and solves them.

If a variable's type isn't constrained, it gets generalised, `id x = x` is inferred as `forall a. a -> a`. GHC's implementation extends HM with type classes, GADTs, type families, rank-N types, and other features, but the core remains HM. Practical consequences: most code doesn't need type annotations, but they're recommended at the top level for documentation and to lock down intent.

When inference 'fails' or types look weird, the usual culprit is the monomorphism restriction or an ambiguous numeric literal, both fixed by adding a type signature. Two limits are worth being able to state precisely. Lambda-bound variables are monomorphic while let-bound ones are generalised, so `\f -> (f 1, f True)` is rejected with 'Couldn't match expected type Bool with actual type Int' and needs `RankNTypes` plus an explicit `forall`.

And inference is undecidable once you leave the HM core, so GADTs, type families, and rank-N types all require signatures you write yourself. In practice you read inference through GHC's errors: 'Couldn't match expected type' means unification failed, 'Ambiguous type variable a0 arising from a use of read' means a constraint has nothing to pin it to, and 'No instance for (Num String)' usually means a literal landed somewhere unintended. Since GHC 9.6 each diagnostic carries a stable code such as [GHC-83865] that you can look up in the error index. The most useful day-to-day trick is typed holes: replace any subexpression with `_` and GHC prints 'Found hole: _ :: [Int] -> Int' along with in-scope bindings that would fit, which turns inference into an interactive tool rather than a black box.

-- No signature: GHC infers the principal type
-- ghci> :t \x -> x
--   forall a. a -> a

-- Constraints are inferred too
-- ghci> :t \x y -> x + y
--   forall a. Num a => a -> a -> a

-- Lambda-bound f is monomorphic, so this is rejected
-- badPair f = (f 1, f True)

-- RankNTypes makes the polymorphism explicit and it compiles
{-# LANGUAGE RankNTypes #-}
goodPair :: (forall a. a -> a) -> (Int, Bool)
goodPair f = (f 1, f True)

-- Typed hole: GHC reports "Found hole: _ :: [Int] -> Int"
-- total :: [Int] -> Int
-- total = _
Q17

What is a `Functor` and how is it related to a Monad?

IntermediateType Classes

Answer

`Functor` is the type class with one method: `fmap :: (a -> b) -> f a -> f b`. It lets you map a pure function over a value-in-a-context without unwrapping. `fmap (+1) (Just 3)` is `Just 4`; `fmap (+1) [1,2,3]` is `[2,3,4]`. Every Monad is also a Functor (and an Applicative).

The hierarchy is `Functor => Applicative => Monad`: Functors apply a pure function; Applicatives apply a function inside a context to a value inside a context; Monads chain computations where each step's input depends on the previous step's output. In practice you use `fmap` (or its operator `<$>`) all the time, even in IO code: `length <$> getLine` reads a line and returns its length without manually unwrapping. The two laws are `fmap id = id` and `fmap f . fmap g = fmap (f . g)`; together they say `fmap` may only touch the values, never the shape, so a 'Functor' that reverses a list or drops elements is illegal.

GHC can write instances for you with `DeriveFunctor`, and there is at most one lawful instance per type, which is why deriving is safe. Kinds matter here: the argument must have kind `Type -> Type`, so `fmap` over `Maybe` works but `fmap` over `Maybe Int` gives 'Expected kind Type -> Type, but Maybe Int has kind Type', and for a two-parameter type only the last parameter is mapped. That last rule is a real footgun: `fmap (+1) (3, 4)` is `(3, 5)` and `fmap (+1) (Left 3)` is `Left 3`, because `(,) a` and `Either e` are functors in their final argument only.

Use `Data.Bifunctor`'s `first` and `second` when you need the other side. Related operators worth naming are `<$` and `$>` for replacing the value, and `void`. Applicative sits between Functor and Monad and is genuinely different, not just weaker: because the effects are independent, a validation applicative can accumulate every error, while a monad short-circuits at the first failure.

import Data.Char (toUpper)

shoutMaybe :: Maybe String -> Maybe String
shoutMaybe = fmap (map toUpper)

-- shoutMaybe (Just "hi") -> Just "HI"
-- shoutMaybe Nothing      -> Nothing

shoutLine :: IO String
shoutLine = map toUpper <$> getLine
Q18

What is the difference between strict and lazy data types?

IntermediatePerformance

Answer

By default, fields of an algebraic data type are lazy, they store thunks. Adding a `!` (bang pattern) before a field makes it strict, the value must be evaluated to weak head normal form before the constructor is applied. The `Data.Map.Strict` and `Data.IntMap.Strict` modules are strict-spined variants of their lazy cousins: the structure is strict but values can still be lazy.

Strict fields prevent space leaks where thunks pile up inside a record (e.g. a counter that keeps growing as `1 + 1 + 1 + ...` instead of evaluating). In modern Haskell, the `StrictData` language pragma makes every field strict in a module, a common choice for performance-sensitive code. To be precise about the container libraries: `Data.Map.Strict` and `Data.Map.Lazy` share the same spine-strict representation, and what differs is whether the stored values are forced when you insert them, so `Map.insertWith (+) k 1` on the lazy variant is the textbook way to accumulate a chain of unevaluated additions per key.

Strictness annotations only reach weak head normal form, which is why `!` on a field of type `[Int]` forces the first cons cell and nothing more; use `NFData` and `force` from `deepseq` when you need the whole structure. Two pragmas are easy to confuse: `StrictData` makes constructor fields strict, while `Strict` makes bindings and function arguments strict too, which is a much bigger behavioural change and can turn a working lazy algorithm into an infinite loop. For unboxing, `{-# UNPACK #-} !Int` stores the machine word directly inside the constructor instead of a pointer to a boxed `Int`, and `-funbox-strict-fields` applies that everywhere. The usual interview follow-up is when laziness in a field is worth keeping: memoised or rarely-read fields, and recursive definitions that refer to themselves, are the honest cases.

-- Lazy by default, `count` might be a thunk
data Stats = Stats { count :: Int, total :: Double }

-- Strict fields, forces evaluation when constructed
data StatsStrict = StatsStrict !Int !Double

-- Or with StrictData pragma
{-# LANGUAGE StrictData #-}
data StatsAuto = StatsAuto { c :: Int, t :: Double }
💡 Pro Tip: If you're writing a loop or fold that updates a counter or accumulator, make those fields strict. This is the single most common space-leak fix.
Q19

What are monad transformers and when do you need them?

IntermediateMonads

Answer

A monad transformer is a type constructor that takes a monad and adds new capabilities to it. Real applications need to combine effects, IO + error handling + configuration + state, but you can't write a function that's in both `IO` and `Either` at the same time. Transformers solve this by stacking: `StateT s (ReaderT r (ExceptT e IO))` is a single monad that supports state, reader config, error throwing, and IO.

The `mtl` library gives you `MonadState`, `MonadReader`, `MonadError`, etc., as type classes so functions only declare the capabilities they need (`MonadIO m => m ()`). The gotcha: transformer stacks add runtime overhead and the type errors get long. In 2026, alternatives like `polysemy`, `fused-effects`, and `effectful` have emerged for effect systems with cleaner semantics, but mtl is still the production default.

The detail that separates people who have maintained a stack from people who have only built one is that order changes semantics. `runExceptT (runStateT act s)` with `StateT s (ExceptT e IO)` discards all state changes when an error is thrown, while `runStateT (runExceptT act) s` with `ExceptT e (StateT s IO)` keeps them, and picking the wrong one silently loses audit data. `WriterT` leaks memory for long-running loops because the accumulator is lazy, so use `StateT` with a strict field instead. `MonadUnliftIO` only has instances for monads isomorphic to `ReaderT r IO`, which is why `withRunInIO`-based libraries refuse to work over `StateT`, and that constraint is the main argument for the widely used ReaderT pattern: define `newtype App a = App (ReaderT Env IO a)` with an `Env` record holding config, connection pools, loggers, and `IORef`s or `TVar`s for state, then derive `MonadReader`, `MonadIO`, and `MonadUnliftIO` with `GeneralizedNewtypeDeriving`. It gives you one shallow stack, predictable exception behaviour, and readable type errors.

import Control.Monad.Reader
import Control.Monad.Except

data Config = Config { dbUrl :: String }
data AppError = NotFound | DbError String

-- An action that can read config, throw errors, and do IO
fetchUser :: (MonadReader Config m, MonadError AppError m, MonadIO m) => Int -> m String
fetchUser uid = do
  cfg <- ask
  liftIO (putStrLn ("Looking up " ++ show uid ++ " in " ++ dbUrl cfg))
  if uid < 0 then throwError NotFound else return "some user"
Q20

What is software transactional memory (STM) in Haskell?

IntermediateConcurrency

Answer

STM is Haskell's flagship concurrency primitive, composable atomic transactions on shared memory, implemented in `Control.Concurrent.STM`. You wrap mutable references in `TVar a`, read/write them inside `atomically :: STM a -> IO a`, and the runtime guarantees the entire block executes as a single atomic transaction, if any other thread changes a `TVar` you read, the transaction retries automatically. Unlike locks, STM blocks compose: you can write two independent STM functions and combine them with `>>=` and they remain atomic together.

There's also `retry` (block until any read `TVar` changes) and `orElse` (try one transaction, fall back to another). STM is why concurrent Haskell often has fewer race-condition bugs than equivalent Go or Java code, and it's heavily used by trading systems at Standard Chartered. What makes it safe is that the `STM` type has no `MonadIO` instance, so the compiler physically prevents you from launching a missile or writing to a socket inside a transaction that may be retried.

The runtime keeps a per-transaction read and write log, validates it at commit, and rolls back and re-runs on conflict. That design has real costs to name in an interview: a transaction that touches a large number of `TVar`s validates in time proportional to that number, and one hot `TVar` written by every thread turns STM into a contention bottleneck, so you shard state across many `TVar`s rather than putting one big `Map` in one. Long transactions can also starve under load, and a transaction that can never make progress dies with 'thread blocked indefinitely in an STM transaction' (`BlockedIndefinitelyOnSTM`), which is usually a lost writer rather than a deadlock.

Beyond `TVar` the package gives you `TMVar`, `TChan`, `TQueue`, and `TBQueue`, and the bounded queue is what you use to get backpressure between producer and consumer. Use `modifyTVar'`, not `modifyTVar`, or you accumulate thunks inside the variable, and build with `-threaded` so the transaction actually runs on multiple capabilities.

import Control.Concurrent.STM

transfer :: TVar Int -> TVar Int -> Int -> STM ()
transfer from to amount = do
  fromBal <- readTVar from
  if fromBal < amount
    then retry  -- block until `from` has enough
    else do
      writeTVar from (fromBal - amount)
      toBal <- readTVar to
      writeTVar to (toBal + amount)

-- run it:
--   atomically (transfer accountA accountB 1000)
Q21

What are green threads in Haskell and how is concurrency different from parallelism?

IntermediateConcurrency

Answer

GHC's runtime uses lightweight green threads, `forkIO` spawns one in microseconds and the runtime can comfortably handle 100,000+ of them, multiplexed onto a small number of OS threads (configurable with `+RTS -N`). Concurrency is about structuring a program as independent threads of execution (one per request, one per chat connection); parallelism is about using multiple CPU cores to make a single computation faster. Haskell handles both: `forkIO` + `MVar`/`STM` for concurrency, `par`/`pseq` from `Control.Parallel.Strategies` or the `parallel` library for parallel computations, and `async` for both.

The lack of a global interpreter lock (unlike CPython) means you genuinely scale on multi-core machines. The async library's `concurrently :: IO a -> IO b -> IO (a, b)` is the typical 2026 idiom. Details that matter in production: you must link with `-threaded` and pass `+RTS -N -RTS` (or bake it in with `ghc-options: -threaded -rtsopts "-with-rtsopts=-N"`) or everything runs on one capability, and `-N` with no number picks up the machine's core count, which in a container is the host's core count, not the cgroup limit.

The scheduler is preemptive only at allocation points, so a tight arithmetic loop that allocates nothing can hold a capability until it finishes. A `safe` foreign call releases the capability while a `unsafe` one blocks it, and a C library that keeps thread-local state needs `forkOS`. The biggest correctness issue is thread lifetime: a bare `forkIO` leaks the thread if the parent dies and swallows its exception silently, so use `withAsync`, `link`, `race`, or `concurrently` from `async`, all of which propagate failures and cancel siblings. For bounded fan-out over a list of jobs, `mapConcurrently` will happily open ten thousand connections, so reach for `pooledMapConcurrentlyN` from `unliftio` instead. `+RTS -s` prints how much time went to GC versus mutator, which is the first number to check when parallel speedup disappoints.

import Control.Concurrent.Async

fetchUser :: Int -> IO User
fetchOrders :: Int -> IO [Order]

userPage :: Int -> IO (User, [Order])
userPage uid = concurrently (fetchUser uid) (fetchOrders uid)
Q22

How do you build an HTTP API in Haskell with Servant?

IntermediateWeb

Answer

Servant is the dominant web framework in Haskell, it represents your API as a type-level DSL, and the server, client, and documentation are derived from that type. You declare endpoints as type-level combinators (`(:>)`, `(:<|>)`, `Capture`, `QueryParam`, `ReqBody`, `Get`, `Post`), provide handlers whose Haskell types must match exactly, and Servant wires it all together. The compiler ensures the handler signature matches the declared API, if you add a `QueryParam Int` to the type and forget the handler argument, it won't compile.

This is far stricter than FastAPI or Express's runtime checks. Servant also generates Swagger/OpenAPI specs and typed clients (Haskell, Elm, JS) for free. Practically, handlers live in `Handler`, which is `ExceptT ServerError IO`, so you signal HTTP failures with `throwError err404 { errBody = "no such user" }` rather than by returning a status code.

Real apps do not want plain `Handler`, so you write `ServerT UserAPI App` for your own monad and convert it at the boundary with `hoistServer api (nt env)` where `nt` runs your `ReaderT Env IO`. You then serve it on Warp with `run 8080 (serve api server)`, or `serveWithContext` when the API uses `BasicAuth` or `AuthProtect`. `servant-client` derives a client from the same type via `client api`, `servant-openapi3` emits the spec, and `Raw` lets you mount static files or a fallback WAI application. The two costs to be honest about are compile time, since a large type-level API can dominate your build, and error messages: a handler whose argument order does not match the type produces a wall of 'Couldn't match type' output that beginners find impenetrable. Servant 0.19 added `NamedRoutes`, which replaces long `:<|>` chains with a record of routes and makes those errors point at the field that is wrong.

import Servant

type UserAPI =
       "users" :> Get '[JSON] [User]
  :<|> "users" :> Capture "id" Int :> Get '[JSON] User
  :<|> "users" :> ReqBody '[JSON] User :> Post '[JSON] User

userServer :: Server UserAPI
userServer = listUsers :<|> getUser :<|> createUser
  where
    listUsers      = liftIO fetchAllUsers
    getUser  uid   = liftIO (fetchUser uid)
    createUser usr = liftIO (insertUser usr)
Q23

What is the Persistent library and how does it differ from typical ORMs?

IntermediateDatabase

Answer

Persistent is the standard Haskell database library, usually paired with Esqueleto for richer queries. You define entity schemas in a quasi-quoted DSL, and Persistent generates Haskell records, type-safe queries, and migration code. Unlike SQLAlchemy or ActiveRecord, Persistent does not 'magic' relationships at runtime, every query is a typed function.

Esqueleto adds a type-safe EDSL for joins, subqueries, and aggregates that looks close to SQL but is checked at compile time. Backends include Postgres, MySQL, SQLite, and MongoDB. The trade-off: less ergonomic for ad-hoc queries (you often drop to raw SQL for complex analytics) but very hard to write a query that doesn't compile-check.

Concretely, the quasi-quoter generates a record plus a phantom-typed `EntityField` per column, so `selectList [UserAge >=. Just 18] [Desc UserAge, LimitTo 20]` is checked against the schema, and results come back as `Entity User` with `entityKey` and `entityVal` rather than a bare record. Keys are `Key User`, not `Int`, so you cannot pass an order id where a user id belongs.

You run everything inside `runSqlPool` against a pool created by `withPostgresqlPool connStr 10`, and pool sizing is the first thing to check when latency spikes. The migration story is the part people get wrong in interviews: `runMigration migrateAll` will add tables and columns but refuses destructive changes, and `printMigration` or `showMigration` shows you the SQL first, which is why most teams run migrations as a deliberate step rather than on service startup. For joins and aggregates you drop into Esqueleto's `select $ from $ \(u :& o) -> ...` with `on`, `where_`, `groupBy`, and `val`, and for anything Esqueleto cannot express there is `rawSql` with explicit result types. Watch for the classic N+1: fetching a list then querying per row, instead of one `selectList [UserId <-. ids] []`.

share [mkPersist sqlSettings, mkMigrate "migrateAll"] [persistLowerCase|
User
    email   String
    age     Int Maybe
    UniqueEmail email
    deriving Show
|]

getUser :: MonadIO m => Int -> SqlPersistT m (Maybe User)
getUser uid = get (toSqlKey (fromIntegral uid))
Q24

What are GHC language extensions and which ones are common in production?

IntermediateTooling

Answer

GHC ships dozens of language extensions on top of Haskell 2010, opt-in features you enable per module with `{-# LANGUAGE ... #-}` or globally in the cabal file. The common production set in 2026: `OverloadedStrings` (string literals work for `Text`, `ByteString` etc.), `ScopedTypeVariables` (let type signatures bind variables in function bodies), `LambdaCase` (`\case` for one-arg case-of), `RecordWildCards` (`User {..}` to bring all fields into scope), `DeriveGeneric` + `DeriveAnyClass` (auto-derive `ToJSON`/`FromJSON`), `TypeApplications` (apply types with `@`), `DataKinds` and `GADTs` (advanced types). The `GHC2021` and `GHC2024` 'language sets' bundle the most stable extensions so you can enable them all at once.

Avoid bleeding-edge extensions like `TypeFamilies` in code touched by junior engineers, they make type errors much harder to read. The version detail interviewers like: `GHC2021` became the default language edition in GHC 9.2 and `GHC2024` arrived in GHC 9.10, so instead of a fifteen-line pragma block you set `default-language: GHC2021` in the cabal file and enable only what is genuinely extra. GHC2021 already includes `ScopedTypeVariables`, `TypeApplications`, `DeriveFunctor`, `GeneralizedNewtypeDeriving`, and `BangPatterns`, which is why modern code looks like it has fewer pragmas.

Newer additions worth knowing are `OverloadedRecordDot` and `NoFieldSelectors` in GHC 9.2, which finally make duplicate record field names workable, `ExtendedLiterals` in GHC 9.8, and `RequiredTypeArguments` in GHC 9.10. `DerivingStrategies` is close to mandatory once you use both `GeneralizedNewtypeDeriving` and `DeriveAnyClass`, because otherwise GHC silently picks one and you get a `ToJSON` instance that serialises nothing; writing `deriving newtype`, `deriving stock`, or `deriving anyclass` makes the choice explicit. `DerivingVia` then lets you reuse an existing instance through a newtype. A reasonable review rule is that `default-extensions` in the cabal file should be a short, boring list, and anything surprising belongs in the module that needs it.

-- In the .cabal file
-- library
--   default-language:   GHC2021
--   default-extensions: OverloadedStrings
--                       LambdaCase
--                       DerivingStrategies
--   ghc-options:        -Wall -Wcompat -Wincomplete-uni-patterns

{-# LANGUAGE DeriveAnyClass     #-}
{-# LANGUAGE DerivingStrategies #-}
{-# LANGUAGE OverloadedRecordDot #-}

import Data.Aeson (FromJSON, ToJSON)
import GHC.Generics (Generic)

-- Explicit strategies: no silent GND-vs-anyclass surprise
newtype UserId = UserId Int
  deriving stock   (Show)
  deriving newtype (Eq, Ord, ToJSON, FromJSON)

data User = User { uid :: UserId, email :: String }
  deriving stock    (Show, Generic)
  deriving anyclass (ToJSON, FromJSON)

-- OverloadedRecordDot
userEmail :: User -> String
userEmail u = u.email
Q25

How do you handle errors in Haskell, exceptions, Either, or both?

IntermediateError Handling

Answer

Haskell has three error-handling mechanisms, and idiomatic code uses different ones for different situations. (1) `Maybe` and `Either e a` for expected business-level failures, the type signature documents that a function can fail, and the caller is forced to handle it. (2) Exceptions via `Control.Exception` for unexpected, asynchronous, or out-of-scope failures like network errors, disk full, or thread cancellation. You `throw`/`throwIO` and catch with `try`/`catch`/`handle`. (3) The `ExceptT` monad transformer when you want `Either`-style errors composed with IO. The rule of thumb: use `Either` when the caller should reasonably handle the failure as part of normal logic, use exceptions when the failure indicates something systemic. `error` and `undefined` produce runtime crashes and should never appear in production code, and the same goes for partial functions like `head`, `fromJust`, and `read`, which is why GHC 9.8 added the `-Wx-partial` warning for `head` and `tail`.

Several sharp edges show up in real services. Catching `SomeException` also catches asynchronous exceptions such as `ThreadKilled` and `AsyncCancelled`, so a retry loop written that way becomes uncancellable; use `Control.Exception.Safe` or `UnliftIO.Exception`, which distinguish sync from async, or catch a specific type with `try @IOException`. Laziness means `try (evaluate (1 `div` 0))` catches the divide by zero while `try (return (1 `div` 0))` does not, because `return` never forces the thunk and the exception surfaces later at the consumer.

Resource cleanup belongs in `bracket`, `bracket_`, or `finally`, never in a manual open and close pair, since an exception between them leaks the handle. Define your own exception type with `instance Exception AppError` and a good `displayException`, and add `HasCallStack` to helpers so the message carries a source location. Finally, `throwIO` sequences with respect to other IO while `throw` fires whenever the thunk is forced, so always prefer `throwIO` in `IO`.

import Control.Exception (try, SomeException)

-- Either for expected business errors
parseAge :: String -> Either String Int
parseAge s = case reads s of
  [(n, "")] | n >= 0 -> Right n
  _                  -> Left "Invalid age"

-- try for unexpected IO errors
readConfig :: FilePath -> IO (Either SomeException String)
readConfig path = try (readFile path)
Q26

How do you write and run tests in Haskell?

IntermediateTesting

Answer

Three test libraries dominate in 2026: (1) HUnit for traditional unit tests with assertions, (2) Hspec for BDD-style nested `describe`/`it` blocks with rich matchers, (3) QuickCheck for property-based testing, you specify invariants and QuickCheck generates random inputs to falsify them. Most projects use Hspec for unit-style tests and QuickCheck for property tests, often via `hspec-discover` to auto-find test files. Tasty is a popular framework that wraps all three.

Run them with `cabal test` or `stack test`. QuickCheck is the killer feature for Haskell, checking algebraic laws (`reverse . reverse = id`, monad laws, lens laws) eliminates whole classes of bugs that example-based tests would miss. The parts that make a QuickCheck suite actually useful are worth naming: an `Arbitrary` instance (or an explicit `Gen` passed with `forAll`, which is usually better because it keeps generators out of orphan instances), a `shrink` implementation so a 400-element counterexample collapses to the three-element one you can read, and `classify` or `cover` so you can prove your generator is not producing empty lists ninety percent of the time. `==>` discards inputs rather than failing them, and a badly conditioned precondition ends in 'Gave up!

Passed only 34 tests'. Bump the sample count with `quickCheckWith stdArgs { maxSuccess = 10000 }` for anything financial. On the Hspec side, `shouldBe`, `shouldSatisfy`, `shouldThrow`, and `shouldMatchList` cover most assertions, `hspec-discover` wires up a `Spec.hs` automatically, and `hspec-wai` lets you hit a Servant application in-process without binding a port.

Round out the suite with golden tests via `tasty-golden` for serialisation formats, `doctest` for examples in Haddock comments, `tasty-bench` or `criterion` for performance regressions, and coverage from `cabal test --enable-coverage`, which drives HPC. Run in CI with `cabal test --test-show-details=direct` so failures print instead of hiding in a log file.

import Test.Hspec
import Test.QuickCheck

spec :: Spec
spec = describe "reverse" $ do
  it "reversing twice is identity" $ property $
    \xs -> reverse (reverse (xs :: [Int])) == xs
  it "reversing a singleton is itself" $
    reverse [1] `shouldBe` [1]

main :: IO ()
main = hspec spec
Q27

What is the Yesod framework and when would you choose it over Servant?

IntermediateWeb

Answer

Yesod is a full-stack Haskell web framework, think Rails or Django for Haskell. It bundles routing, templating (Hamlet/Lucius/Julius), forms, sessions, authentication, and Persistent for the database into one cohesive package, all stitched together by Template Haskell to catch errors at compile time. Servant is API-first: it gives you a typed HTTP layer and lets you bring your own database, auth, etc. Choose Yesod for traditional server-rendered web apps where you want batteries included; choose Servant for APIs (especially when you also need typed clients in other languages).

In 2026 most new Haskell web work is Servant + a JS frontend; Yesod is more common in legacy apps and admin panels. The concrete pieces are: a route table processed by `mkYesod`, which generates a type-safe URL datatype so `@{UserR uid}` in a template is checked at compile time and a renamed route breaks the build instead of producing a 404; Hamlet, Lucius, and Julius templates that are compiled into the binary by Template Haskell, with interpolation checked against your types; `defaultLayout` and widgets, which let a component carry its own CSS and JavaScript and have them deduplicated in the final page; and `runFormPost` with applicative forms for validation. `yesod devel` gives auto-recompiling local development, `stack new my-app yesodweb/postgres` scaffolds a project with Persistent and `YesodAuth` already wired, and `yesod-test` drives the app end to end. Both frameworks sit on WAI and Warp, so middleware and deployment look identical. The real trade-off to state in an interview is Template Haskell: it is what buys the compile-time checking, and it also slows builds noticeably, complicates cross-compilation, and makes some HLS features less reliable in template-heavy modules.

{-# LANGUAGE QuasiQuotes, TemplateHaskell, TypeFamilies, OverloadedStrings #-}
import Yesod

data App = App

mkYesod "App" [parseRoutes|
/           HomeR GET
/users/#Int UserR GET
|]

instance Yesod App

getHomeR :: Handler Html
getHomeR = defaultLayout $ do
  setTitle "Home"
  -- @{UserR 7} is checked at compile time, not a raw string
  [whamlet|<a href=@{UserR 7}>See user 7|]

getUserR :: Int -> Handler Value
getUserR uid = returnJson (object ["id" .= uid])

main :: IO ()
main = warp 3000 App
Q28

How does JSON encoding and decoding work with aeson, and what changed in aeson 2.0?

IntermediateSerialization

Answer

`aeson` is the standard JSON library. You either derive instances (`deriving stock Generic` plus `deriving anyclass (ToJSON, FromJSON)`) or write them by hand with `withObject "User" $ \o -> User <$> o .: "id" <*> o .:? "email" .!= ""`, where `.:` fails on a missing key, `.:?` yields `Nothing`, and `.!=` supplies a default. Decode with `eitherDecode`, not `decode`: the former returns messages like 'Error in $.items[0].age: parsing Int failed, expected Number, but encountered String' while the latter collapses everything to `Nothing`, which is the single biggest time sink when debugging a failing webhook.

For output, define `toEncoding = genericToEncoding defaultOptions` as well as `toJSON`, because `Encoding` writes straight into a `ByteString` builder and skips building an intermediate `Value` tree. Field naming is controlled by `Options`, mainly `fieldLabelModifier = camelTo2 '_'`, `omitNothingFields = True`, and `sumEncoding` for how constructors are tagged. The aeson 2.0 break is the one interviewers ask about: objects moved from `HashMap Text Value` to `Data.Aeson.KeyMap.KeyMap Value` with a dedicated `Key` type, done to close a hash-collision denial-of-service where attacker-chosen keys degraded insertion. Upgrading means importing `Data.Aeson.KeyMap` and converting with `Key.fromText` and `Key.toText`.

{-# LANGUAGE DeriveAnyClass, DeriveGeneric, OverloadedStrings #-}
import Data.Aeson
import qualified Data.ByteString.Lazy as BL
import GHC.Generics (Generic)

data User = User { userId :: Int, userEmail :: Maybe String }
  deriving stock (Show, Generic)

-- snake_case keys, drop Nothing fields, stream the output
jsonOpts :: Options
jsonOpts = defaultOptions
  { fieldLabelModifier = camelTo2 '_'
  , omitNothingFields  = True
  }

instance ToJSON User where
  toJSON     = genericToJSON jsonOpts
  toEncoding = genericToEncoding jsonOpts

instance FromJSON User where
  parseJSON = withObject "User" $ \o ->
    User <$> o .:  "user_id"
         <*> o .:? "user_email"

-- eitherDecode gives a JSON path on failure
parse :: BL.ByteString -> Either String User
parse = eitherDecode
Q29

What are `Foldable` and `Traversable`, and what are their common footguns?

IntermediateType Classes

Answer

`Foldable` abstracts 'can be reduced to a summary value': its minimal method is `foldMap :: Monoid m => (a -> m) -> t a -> m`, and from it you get `foldr`, `toList`, `length`, `sum`, `elem`, `null`, and `maximum` for lists, `Maybe`, `Map`, `Set`, `Seq`, and any container you derive with `DeriveFoldable`. `Traversable` adds effects to the traversal: `traverse :: Applicative f => (a -> f b) -> t a -> f (t b)`, so `traverse validateRow rows :: Either Err [Row]` walks the structure, threads the failure, and rebuilds the same shape. `sequenceA` turns `[IO a]` into `IO [a]`, `mapM` is just `traverse` at a `Monad`, and `traverse_`, `for_`, and `mapM_` are the versions that discard results and avoid retaining the whole list. The footguns are all consequences of these classes being defined over the last type parameter. `length (Just 3)` is 1, `sum (2, 3)` is 3, and `length ((), [1,2,3])` is 1, so a refactor that changes a list into a tuple or a `Maybe` compiles and silently returns nonsense; `-Wall -Wcompat` catches some of it, and hlint's suggestion to use `null` instead of `length xs == 0` avoids forcing the spine. `maximum`, `minimum`, `head`, and `foldr1` throw 'Prelude.maximum: empty list' on an empty structure. Use `foldl'` for strict accumulation, which base 4.20 (GHC 9.10) finally exports from the Prelude.

import Data.Foldable (for_, traverse_)
import Data.Traversable (for)

data Row = Row { rowId :: Int, amount :: Int } deriving Show

validate :: Row -> Either String Row
validate r
  | amount r < 0 = Left ("negative amount on row " ++ show (rowId r))
  | otherwise    = Right r

-- Stops at the first bad row, keeps the list shape on success
validateAll :: [Row] -> Either String [Row]
validateAll = traverse validate

-- Effects only, nothing retained
logAll :: [Row] -> IO ()
logAll = traverse_ (print . rowId)

-- for is traverse with the arguments flipped
fetchAll :: [Int] -> IO [Row]
fetchAll ids = for ids $ \i -> pure (Row i 0)

-- Footgun: this is 1, not 3
surprising :: Int
surprising = length (Just [1,2,3 :: Int])
Q30

How do you process a large file or stream in constant memory in Haskell?

IntermediateStreaming

Answer

Not with `readFile`. Lazy IO ties file reading to evaluation order, so the handle stays open until the last character is forced, which produces two classic bugs: the file is closed by `withFile` before the consumer reads it and you get an empty result, or you open the same file for writing while a lazy read is still pending and hit 'openFile: resource busy (file is locked)'. Exception safety is also gone, because nothing guarantees when cleanup runs.

The fix is a streaming library that makes the consumption explicit. `conduit` is the most common: you build a pipeline with `.|`, run it with `runConduitRes`, and `ResourceT` guarantees handles are released even on an exception. `Data.Conduit.Combinators` gives you `sourceFile`, `decodeUtf8C`, `linesUnboundedC`, `mapC`, `filterC`, `foldlC`, and `sinkFile`, and because each chunk is processed and discarded, a 20 GB file runs in a few megabytes of residency. `pipes` is the same idea with a smaller, more principled core, and `streamly` targets performance, relying on GHC's fusion so a stream pipeline compiles down to a loop with no intermediate allocation. The other half of the answer is backpressure: a conduit pulls from upstream on demand, so a slow database sink naturally throttles a fast HTTP source, whereas hand-rolled `forkIO` plus an unbounded queue will grow until the process is killed by the OOM killer.

import Conduit
import qualified Data.Text as T

-- Count matching lines in a huge file, constant memory
countErrors :: FilePath -> IO Int
countErrors path = runConduitRes $
       sourceFile path
    .| decodeUtf8C
    .| linesUnboundedC
    .| filterC (T.isInfixOf "ERROR")
    .| lengthC

-- Transform one file into another without loading either
redactFile :: FilePath -> FilePath -> IO ()
redactFile inp out = runConduitRes $
       sourceFile inp
    .| decodeUtf8C
    .| linesUnboundedC
    .| mapC (T.replace "password" "[redacted]")
    .| unlinesC
    .| encodeUtf8C
    .| sinkFile out
💡 Pro Tip: If a service's memory grows with input size rather than with concurrency, the cause is almost always a lazy `readFile`, a `mapM` over a large list, or an unbounded `Chan` between two threads.
Q31

How do you diagnose and fix a space leak in a production Haskell service?

AdvancedPerformance

Answer

Space leaks, unbounded memory growth from accumulating thunks, are the #1 production problem in Haskell. Diagnosis: (1) Compile with `-rtsopts` and run with `+RTS -hT -p` to get a memory profile by closure type, then visualise with `hp2pretty` or `eventlog2html`. (2) For more detail, use biographical profiling (`-hb`) to find values that are allocated long before they're used. (3) `ghc-debug` (post-2022) lets you connect to a running process and walk the heap interactively. Fixes: (1) Add strictness with `BangPatterns` or `seq` at fold accumulators, `foldl'` instead of `foldl`. (2) Use `Data.Map.Strict` / `Data.IntMap.Strict` over the lazy variants when you don't need lazy values. (3) Add `!` to record fields that are accumulators or counters. (4) Replace `String` with `Text` or `ByteString`, `String` is a lazy linked list of `Char` and a known memory hog. (5) For deeply nested computations, force intermediate results with `deepseq`.

The mental model: Haskell defaults to lazy, and you opt into strictness exactly where laziness causes problems. Two refinements are worth adding in a senior interview. First, distinguish a leak from legitimate growth by watching live bytes after a major collection rather than RSS, because GHC's copying collector holds on to memory it has already released back internally.

Second, the modern profiling workflow is info-table profiling: build with `-finfo-table-map -fdistinct-constructor-tables`, run with `+RTS -hi -l`, and render with eventlog2html, which maps each retained closure back to the exact source line that allocated it. That is a large improvement over `-hT`, which only tells you the closure type. `-hr` (retainer profiling) answers the follow-up question of who is holding the memory, and `ghc-debug` lets you attach to a live process and walk the heap. The usual repeat offenders in a web service are `modifyIORef` without the prime, `Data.Map.Lazy.insertWith` used as a counter, lazy `WriterT` in a long-running loop, an unbounded `Chan` between a fast producer and a slow consumer, and `mapM` over a large result set where `mapM_` or a conduit would do.

-- 1. Build with profiling and a heap-profile-capable RTS
--    cabal build --enable-profiling
--    cabal run myservice -- +RTS -hT -l -RTS
--    eventlog2html myservice.eventlog   # opens an interactive heap chart

import Data.List (foldl')
import qualified Data.Map.Strict as M

-- LEAKS: foldl builds 10M nested (+) thunks before forcing
badTotal :: [Int] -> Int
badTotal = foldl (+) 0

-- FIXED: strict accumulator, constant space
goodTotal :: [Int] -> Int
goodTotal = foldl' (+) 0

-- LEAKS: lazy Map values accumulate (+1) chains per key
-- import qualified Data.Map.Lazy as ML
-- tallyBad = foldl' (\m k -> ML.insertWith (+) k 1 m) ML.empty

-- FIXED: strict map forces values on insert
tally :: [String] -> M.Map String Int
tally = foldl' (\m k -> M.insertWith (+) k 1 m) M.empty

-- FIXED: strict, unpacked accumulator record
data Acc = Acc {-# UNPACK #-} !Int {-# UNPACK #-} !Double

Key Points

  • Profile first: -hT, -hb, ghc-debug
  • foldl' over foldl for accumulators
  • Strict record fields with BangPatterns / StrictData
  • Strict Map/IntMap variants
  • Text/ByteString over String in hot paths
Q32

How would you architect a high-throughput trading or payments system in Haskell?

AdvancedArchitecture

Answer

Banking-grade Haskell services (Standard Chartered's Strats group, Mercury's banking platform, Cardano's settlement layer) follow a recognisable pattern. The data layer uses strict records (`StrictData` everywhere), `Text`/`ByteString` for strings, and event sourcing with append-only logs (Kafka or custom Postgres) rather than mutable state. Domain logic is split into a pure core (no IO, fully testable, often heavily QuickChecked) and an imperative shell (Servant handlers, Persistent calls, RabbitMQ producers).

Concurrency uses STM aggressively for shared in-memory state (positions, order books), atomicity composes far better than locks. For inter-service communication, gRPC via `grpc-haskell` or HTTP via Servant. Reliability comes from: (1) types that make invalid states unrepresentable (newtypes for currency, amounts, account IDs), (2) total functions enforced via `-Wall -Werror`, (3) property tests on the pure core (settlement maths, order matching), and (4) `async` with structured concurrency so partial failures don't leak threads.

Deployment is typically Nix-based for reproducible builds, prometheus + grafana for metrics, ekg for runtime stats. A few decisions carry disproportionate weight. Money is never a `Double`: use a fixed-point representation such as `Data.Fixed` or an integer count of minor units wrapped in a newtype, so rounding is explicit and `Eq` is meaningful.

Every externally triggered action carries an idempotency key, and the write path uses the transactional outbox pattern (insert the event and the outbox row in one database transaction, publish afterwards) rather than writing to Postgres and Kafka separately and hoping both succeed. Because the pure core is deterministic, you can replay the event log against a new build and diff the resulting state, which is the cheapest possible regression test for settlement logic. Between stages, a `TBQueue` gives you real backpressure instead of an unbounded queue that grows until the pod is OOM-killed.

On the runtime side, pin `-N` to the container's CPU quota, raise the nursery with `-A64m`, and consider `--nonmoving-gc` when tail latency matters more than throughput. Observability is `katip` or `fast-logger` for structured logs plus `hs-opentelemetry` for traces, and a `getRTSStats` gauge so a GC regression is visible before a customer notices.

Q33

Explain how lazy evaluation is implemented at the runtime level.

AdvancedRuntime

Answer

GHC implements laziness via thunks, heap-allocated closures that represent unevaluated computations. Every let-binding, function argument, and constructor field is, by default, a thunk: a pointer to a small heap object containing code + free variables needed to compute the value. When the value is demanded (pattern matched, used arithmetically, etc.), the runtime jumps to the thunk's code, computes the value, overwrites the thunk in place with the result (this is called 'updating' the thunk), and returns.

Subsequent demands hit the already-evaluated result. This is the Spineless Tagless G-machine (STG), GHC's intermediate representation that maps cleanly to fast machine code. Key consequences: (1) thunks cost memory and a level of indirection, so naive code can be slower than strict code, (2) updating thunks needs to be safe under concurrency, GHC uses 'black holes' to mark thunks under evaluation, (3) the optimizer (`-O2`) inlines and forces aggressively, often eliminating thunks where laziness isn't observable.

Understanding STG is the difference between writing 'works' Haskell and writing 'fast' Haskell. Three further details separate a rehearsed answer from a real one. GHC uses an eval/apply calling convention rather than push/enter, and it tags pointers with the constructor index in the low bits, so matching on `Just`/`Nothing` usually costs no memory access at all. `seq` is not a magic primitive: it compiles to a `case` scrutinising the value, which is exactly what forcing means at the STG level.

And a thunk the compiler can prove is entered only once is built as a single-entry thunk with no update frame, which is why adding a second use of a value can change allocation behaviour. The optimizer can also work against you: the full laziness transform floats a subexpression out of a lambda so it is computed once, which is a win for a lookup table and a leak when the floated value is a large list retained across calls, so `-fno-full-laziness` on a specific module is an occasional real fix. If a thunk depends on itself the runtime detects re-entering a black hole and you get `<<loop>>` instead of a hang. Inspect all of this with `-ddump-stg-final` and `-ddump-cmm`.

Q34

What is the role of category theory in Haskell, do you actually need it?

AdvancedTheory

Answer

Category theory is the mathematical foundation behind Haskell's type-class hierarchy, `Functor`, `Applicative`, `Monad`, `Monoid`, `Foldable`, `Traversable` all correspond to categorical structures, and the laws each instance must obey (functor identity/composition, monad left-identity/right-identity/associativity) are imported directly from category theory. In day-to-day production Haskell, you don't need to understand the theory to use these classes, most working Haskellers use monads without ever drawing a commutative diagram. But the abstractions exist because category theory found them, and the laws guarantee composition behaves predictably (e.g. `>>=` is associative, which is why `do` blocks can be refactored freely).

When you start writing your own type class hierarchies, library APIs, or effect systems, knowing the laws and proving instances satisfy them prevents subtle bugs. In interviews for senior Haskell roles (especially at IOG/Cardano or academic-adjacent shops), expect questions on the laws even if you never compute a natural transformation in production. Be able to state the laws you actually rely on. `Monoid` associativity is what lets `foldMap` split a fold across chunks or cores and combine the partial results in any grouping, which is the real reason your metrics aggregation is correct.

The monad laws are what make extracting a helper from the middle of a `do` block a safe refactor. And a natural transformation is not exotic once you have written `forall a. m a -> n a`: that is precisely the argument `hoistServer` takes in Servant to run handlers in your own monad, and what `hoist` does in `mmorph`. Kleisli composition `>=>` is the associativity law written as an operator.

Free monads and the adjunctions behind them are the machinery under `polysemy`-style effect systems. The practical position to take in an interview is honest and defensible: you do not need category theory to write application code, you do need the laws the moment you publish a type class or an effect interface others will implement, and you can check them mechanically with `quickcheck-classes` or `hedgehog-classes` rather than proving them by hand.

Q35

How do effect systems like polysemy, fused-effects, and effectful compare to mtl?

AdvancedEffect Systems

Answer

MTL (monad transformer library) has been the workhorse for effectful Haskell since the 2000s, but it has well-known issues: (1) the n² instances problem when adding new effects, (2) performance, each layer of the stack adds a runtime dispatch, (3) ordering matters (`StateT s (ExceptT e IO)` and `ExceptT e (StateT s IO)` behave differently on exception). Modern alternatives in 2026: (1) `polysemy`, free-monad-based, very expressive, but historically slow until GHC 9+ optimizations caught up. (2) `fused-effects`, uses higher-rank polymorphism to fuse handlers at compile time, fast but the type errors are notoriously hard. (3) `effectful`, newer, simpler API, built around `IO` as the base, performance competitive with mtl, growing adoption. The pragmatic stance: mtl is still the default in most production codebases because of ecosystem inertia and developer familiarity; `effectful` is the most likely successor; `polysemy` and `fused-effects` are powerful but have steeper learning curves.

The differences don't usually matter for small apps, they matter when your effect stack grows past 4-5 layers. Mechanically, `effectful` looks different from mtl in a way worth describing: an action has type `Eff es a` where `es` is a type-level list of effects, functions declare what they need with constraints like `(Reader Config :> es, IOE :> es)`, and the environment is a mutable array rather than a tower of newtypes, which is where the performance comes from. `runPureEff` will not compile if `IOE` is in the row, so 'this function does no IO' becomes a checked property instead of a convention. The hard part in every effect system, and the thing a senior interviewer probes, is higher-order effects: `local`, `catch`, and `bracket` take an action as an argument, and getting them right under an arbitrary interpreter is where free-monad designs historically produced surprising or resource-unsafe behaviour.

The payoff that justifies the migration is testing: define a `Database` effect, run it against Postgres in production and against a pure in-memory handler in tests, with no mocking library and no `IO` in the test path. If you cannot point to that benefit, mtl with the ReaderT pattern is the cheaper answer.

Q36

A Haskell service has p99 latency spikes and high GC time. How do you tune the GHC RTS?

AdvancedProduction

Answer

Start with measurement, not flags. Run with `+RTS -s` and read three numbers: total allocation, productivity (mutator time over elapsed), and maximum pause. Turn on `-T` so `GHC.Stats.getRTSStats` is available and export `gcdetails_elapsed_ns` and live bytes to your metrics backend, since latency spikes that correlate exactly with GC are a different problem from spikes that correlate with a slow dependency.

Then use `-l` to write an eventlog and view it in ThreadScope or eventlog2html to see which capability stalls. The usual causes and fixes: GHC's young generation is copying, so raising the nursery with `-A64m` (the default is only a few megabytes) cuts minor collection frequency dramatically at the cost of resident memory, and it is the single highest-value flag for a request-serving process. If pauses come from the old generation, `--nonmoving-gc` (GHC 8.10 and later) replaces the stop-the-world major collection with a concurrent mark-and-sweep, trading throughput and some fragmentation for much shorter pauses. `-qg` disables parallel GC when the collector is thrashing on a small heap, and `-M` caps the heap so the process dies with 'Heap exhausted' instead of being OOM-killed with no diagnostics.

The container trap is `-N` with no number: the RTS reads the host's core count, not the cgroup CPU quota, so a two-CPU pod spawns dozens of capabilities and spends its time in GC synchronisation. Pin it explicitly.

-- In the .cabal file: bake in defaults, still allow overrides
-- executable myservice
--   ghc-options: -O2 -threaded -rtsopts
--                "-with-rtsopts=-N4 -A64m -T --nonmoving-gc"

-- Override at run time without rebuilding:
--   ./myservice +RTS -N4 -A128m -s -RTS
--   GHCRTS='-N4 -A64m' ./myservice        # env var form, handy in k8s

import GHC.Stats
import Data.Word (Word64)

-- Export GC health to Prometheus / SigNoz (needs +RTS -T)
gcHealth :: IO (Word64, Word64, Double)
gcHealth = do
  s <- getRTSStats
  let liveBytes  = gcdetails_live_bytes (gc s)
      lastPause  = gcdetails_elapsed_ns (gc s)
      prodRatio  = fromIntegral (mutator_elapsed_ns s)
                 / fromIntegral (max 1 (elapsed_ns s))
  pure (liveBytes, lastPause, prodRatio)

Key Points

  • -s and -T first, then eventlog with -l
  • -A64m is the highest-value single flag for request servers
  • --nonmoving-gc trades throughput for shorter major pauses
  • -N with no number reads host cores, not the cgroup limit
  • -M caps the heap so you get 'Heap exhausted', not an OOM kill
Q37

How do you keep a concurrent Haskell service resource-safe under cancellation and timeouts?

AdvancedConcurrency

Answer

GHC delivers cancellation as an asynchronous exception: `killThread` is `throwTo tid ThreadKilled`, `timeout` throws its own `Timeout` exception into the running thread, and `race` and `cancel` from `async` do the same to the loser. That means any thread can be interrupted at almost any allocation point or interruptible operation, so correctness depends on where you allow that to happen. The core tool is `bracket acquire release use`, which masks async exceptions around acquisition and runs the release action under `uninterruptibleMask` so cleanup cannot itself be cancelled halfway. `finally` and `onException` are the narrower versions, and `mask $ \restore -> ...` is what you use when you need to acquire and register a resource atomically before re-enabling interrupts.

Three failure modes come up in production. Catching `SomeException` in a worker loop swallows `ThreadKilled` and produces a service that ignores SIGTERM until Kubernetes force-kills it: use `Control.Exception.Safe` or `UnliftIO.Exception`, whose `catch` only catches synchronous exceptions. `cancel` blocks until the target finishes cleanup, so a cleanup handler that itself blocks on a `takeMVar` turns shutdown into a hang. And `uninterruptibleMask` around anything that can block forever is how you lose the ability to kill a thread at all. Prefer `withAsync` over `forkIO` so lifetimes are lexically scoped, and `ResourceT` when acquisition order is dynamic.

import Control.Concurrent.Async (withAsync, waitEither, race_)
import Control.Exception (bracket, mask)
import System.Timeout (timeout)
import UnliftIO.Exception (catchAny)   -- ignores async exceptions

-- Cleanup runs even if the thread is cancelled mid-use
withConn :: Pool -> (Conn -> IO a) -> IO a
withConn pool = bracket (takeConn pool) (putConn pool)

-- Scoped thread: killed and awaited when the block exits
serveWithHeartbeat :: IO () -> IO () -> IO ()
serveWithHeartbeat heartbeat server =
  withAsync heartbeat $ \_hb -> server

-- timeout throws into the action, so bracket still cleans up
fetchOr503 :: Pool -> IO (Maybe Row)
fetchOr503 pool = timeout 2000000 (withConn pool queryRow)

-- WRONG: swallows ThreadKilled, service ignores SIGTERM
-- loop = forever (step `catch` \(_ :: SomeException) -> pure ())
-- RIGHT: catchAny from unliftio rethrows async exceptions
workerLoop :: IO () -> IO ()
workerLoop step = step `catchAny` \e -> print e >> workerLoop step
Q38

When do GADTs, DataKinds and type families earn their keep, and what do they cost?

AdvancedType System

Answer

GADTs let each constructor fix the result type, so `IntLit :: Int -> Expr Int` and `BoolLit :: Bool -> Expr Bool` live in the same datatype and `eval :: Expr a -> a` becomes total with no runtime tag checking and no `Either`-shaped plumbing. Pattern matching on a GADT constructor brings a local type equality into scope, which is why the `Add` branch can do arithmetic that the `If` branch cannot. DataKinds promotes constructors to the type level, giving you phantom state machines such as `Conn 'Open` versus `Conn 'Closed`, where `close :: Conn 'Open -> IO (Conn 'Closed)` makes double-close a compile error; the same machinery plus `Symbol` and `TypeOperators` is exactly how Servant encodes routes.

Type families are functions on types, either associated with a class (the standard way to let each database backend choose its own key representation) or closed for type-level computation, and `GHC.TypeLits.TypeError` lets a library replace an unreadable failure with a written sentence. The costs are real and interviewers want to hear them. Inference stops helping, so every function needs an explicit signature and often a `forall` with `ScopedTypeVariables`.

Errors become long, with 'Could not deduce a ~ Int from the context' or a blown `-freduction-depth` on a recursive family. Compile times climb. And the code becomes unmaintainable by teammates who have not learned the vocabulary, which is why most production codebases keep type-level tricks inside library boundaries and hand application developers ordinary ADTs.

{-# LANGUAGE GADTs, DataKinds, KindSignatures #-}
import Data.Kind (Type)

-- GADT: constructors refine the result type, so eval is total
data Expr a where
  IntLit  :: Int  -> Expr Int
  BoolLit :: Bool -> Expr Bool
  Add     :: Expr Int -> Expr Int -> Expr Int
  If      :: Expr Bool -> Expr a -> Expr a -> Expr a

eval :: Expr a -> a
eval (IntLit n)   = n
eval (BoolLit b)  = b
eval (Add x y)    = eval x + eval y
eval (If c t e)   = if eval c then eval t else eval e

-- DataKinds: connection state tracked in the type
data State = Open | Closed

data Conn (s :: State) where
  MkConn :: Handle -> Conn s

openConn  :: String -> IO (Conn 'Open)
closeConn :: Conn 'Open -> IO (Conn 'Closed)

-- closeConn twice is a type error:
--   Couldn't match type 'Closed with 'Open
Q39

How does GHC optimize Haskell code, and how do you verify an optimization actually fired?

AdvancedPerformance

Answer

With `-O2`, GHC desugars to Core and then runs the simplifier repeatedly, interleaved with a handful of major passes. Inlining substitutes small or `INLINE`-marked definitions and is what makes higher-order code cheap; cross-module inlining only happens if the unfolding was exported, which is why library authors mark polymorphic helpers `INLINABLE`. Specialisation removes type-class dictionary passing by generating a monomorphic copy, either automatically or on demand with `{-# SPECIALISE toRow :: User -> Row #-}` and `-fspecialise-aggressively`.

Strictness analysis plus the worker-wrapper transform turns a function on a boxed `Int` into a loop on an unboxed `Int#`, eliminating allocation. Rewrite rules do the algebra: `{-# RULES "map/map" forall f g xs. map f (map g xs) = map (f . g) xs #-}` is a user-writable optimization, and the foldr/build and stream-fusion rules in `base`, `vector`, and `text` are what remove intermediate lists. Verification is the part people skip. `-ddump-simpl -dsuppress-all -dsuppress-uniques -ddump-to-file` gives readable Core where you check whether the dictionary argument disappeared and whether the loop works on `Int#`. `-ddump-rule-firings` prints lines like 'Rule fired: fold/build' so you can confirm fusion rather than assume it.

For a regression guard, the `inspection-testing` library turns 'this function allocates no lists' or 'no dictionaries remain' into a test that fails at compile time. Never benchmark in GHCi, which is interpreted and unoptimized; use `tasty-bench` or `criterion` on a `-O2` build.

-- Inspect what the optimizer did:
--   ghc -O2 -ddump-simpl -dsuppress-all -dsuppress-uniques \
--       -ddump-to-file Hot.hs        # writes Hot.dump-simpl
--   ghc -O2 -ddump-rule-firings Hot.hs | grep "Rule fired"

module Hot (sumSquares, toRow) where

-- Fuses to a single loop with no intermediate list under -O2
sumSquares :: Int -> Int
sumSquares n = sum [x * x | x <- [1 .. n], odd x]

-- Force a monomorphic copy so the Show dictionary is not passed at run time
{-# SPECIALISE toRow :: Int -> String #-}
toRow :: Show a => a -> String
toRow = show
{-# INLINABLE toRow #-}

-- A user-written rewrite rule; NOINLINE keeps the name around to match on
{-# RULES "double/double" forall x. double (double x) = quadruple x #-}
{-# NOINLINE double #-}
double, quadruple :: Int -> Int
double x = x * 2
quadruple x = x * 4
Q40

How do you test a stateful Haskell system beyond example-based unit tests?

AdvancedTesting

Answer

The ladder goes: unit tests, then laws and round trips, then model-based state machine tests. Round-trip properties are the cheapest high-value tests: `decode (encode x) == Right x` for every aeson instance, and `tripping x encode decode` in Hedgehog gives that in one line with a readable diff when it fails. Law checking is next: `quickcheck-classes` and `hedgehog-classes` will verify the `Functor`, `Applicative`, `Monad`, `Semigroup`, and `Ord` laws for your types automatically, which catches the hand-written instance that looks fine and breaks associativity.

Hedgehog is worth preferring over QuickCheck for new suites because shrinking is integrated into the generator rather than written as a separate `shrink` function, so a shrunk counterexample still satisfies the invariants your generator enforced; QuickCheck's separate `shrink` often produces counterexamples that were never valid inputs. For stateful systems, write a model: a pure, obviously correct simplified version of the component. A state machine test (Hedgehog's `Command`, or `quickcheck-state-machine`) generates random sequences of operations, checks preconditions, runs each command against both the model and the real system, and asserts a postcondition after every step; running the same sequences in parallel and checking linearizability is how people find race conditions in `MVar` and `STM` code that no unit test would reach. Add `tasty-golden` for wire formats so a schema change shows up as a diff in review, and always record the printed seed so a CI failure is reproducible.

import           Hedgehog
import qualified Hedgehog.Gen   as Gen
import qualified Hedgehog.Range as Range
import           Data.Aeson (encode, eitherDecode)

genUser :: Gen User
genUser = User
  <$> Gen.int (Range.linear 1 100000)
  <*> Gen.maybe (Gen.string (Range.linear 3 40) Gen.alphaNum)

-- Round trip: shrinks to the smallest failing user automatically
prop_json_roundtrip :: Property
prop_json_roundtrip = property $ do
  u <- forAll genUser
  tripping u encode eitherDecode

-- An invariant of the real thing, not an example
prop_balance_never_negative :: Property
prop_balance_never_negative = withTests 5000 . property $ do
  ops <- forAll (Gen.list (Range.linear 0 200) genOp)
  bal <- evalIO (runOps ops)
  assert (bal >= 0)

main :: IO Bool
main = checkParallel $$(discover)
💡 Pro Tip: If a property never fails, check that it can: temporarily break the implementation and confirm the test catches it, and use `cover` or `classify` to prove the generator reaches the interesting cases.

Companies Hiring Haskell

Standard Chartered
Juspay
Tata Consultancy Services
Input Output Global (Cardano)
GitHub (Semantic)
Mercury
Tweag
Well-Typed

Salary Insights

Average in India
₹10-30 LPA

Frequently Asked Questions

Is Haskell worth learning in 2026?

If you care about programming-language theory, type systems, or working at FP-shops, banks (Standard Chartered, Mercury), blockchain teams (Cardano, Tezos), or compiler/static-analysis tooling (GitHub Semantic, ShellCheck), yes. Haskell jobs in India are rarer than Java or Python jobs but pay at the premium end (₹10-30 LPA). For most general-purpose backend roles, Rust or Go are more pragmatic choices in 2026.

How much does a Haskell developer earn in India?

₹10-30 LPA in 2026 depending on experience and domain. The premium end is finance (Standard Chartered Bangalore, Juspay) and blockchain (Cardano stake-pool operators, custom token contracts). TCS and a few smaller boutiques have Haskell teams as well. The market is small but well-paid because the supply of working Haskellers is genuinely limited.

How long does it take to prepare for a Haskell interview?

If you already write code professionally in another language, plan on six to eight weeks of evenings rather than a weekend of revision, because most of the work is building intuition, not memorising syntax. A workable split: two weeks on types, ADTs, pattern matching, and type classes until you stop fighting the compiler; two weeks on `Functor`, `Applicative`, `Monad`, and a transformer stack, learned by building something real such as a small Servant API backed by Persistent; two weeks on the topics that separate candidates, meaning laziness and WHNF, `foldl` versus `foldl'`, space leaks, STM, and reading a heap profile. Spend the last stretch on the tooling questions people fail: GHCup, `cabal build` versus `stack build`, which extensions your `default-language: GHC2021` already gives you, and how to run a test suite. If you are converting from Python or Java, expect the first two weeks to feel slower than you planned; that is normal and it passes.

Do Haskell roles in India hire freshers, or only experienced developers?

Most advertised Haskell roles ask for two or more years of experience, because the teams are small and cannot absorb a long ramp-up. The realistic fresher route is not a Haskell job title at all: it is joining a company that hires generalists and trains them on its functional stack (Juspay in Bangalore is the best-known example in India), or entering through a domain, since blockchain and quantitative finance teams will take a strong fresher who already reads type signatures comfortably. Two things move a fresher's application more than a certificate: merged pull requests on a Hackage package or a well-documented project of your own (a parser, an interpreter, a JSON API with tests), and being able to talk about a real bug you fixed, such as a space leak you found with a heap profile. Freshers who do get in start at the lower end of the ₹10-30 LPA band, and the gap closes fast because experienced Haskell developers are genuinely scarce.

What's the difference between Haskell and other functional languages like Scala or F#?

Haskell is purely functional, every side effect is reflected in the type. Scala and F# are hybrid, they allow mutation and side effects freely, with FP as a style rather than a guarantee. Haskell is lazy by default; Scala and F# are strict. Scala targets the JVM (and integrates with the Java ecosystem); F# targets .NET; Haskell compiles to native via GHC. If you want strict FP guarantees, Haskell is unmatched. If you want FP-on-the-JVM with library access, Scala wins.

What should I read first to learn Haskell properly?

The 2026 recommended path: (1) 'Learn You a Haskell for Great Good!' or the free 'Haskell from First Principles' chapters for syntax + intuition, (2) 'Programming in Haskell' (Hutton, 2nd ed.) for a more rigorous treatment, (3) 'Haskell in Depth' or 'Real World Haskell' (updated chapters online) for production patterns, and (4) the GHC user guide for extensions you'll actually use. Pair the reading with building something non-trivial, a parser, a small web service with Servant, or contributing to a Hackage library.

Why do people say monads are hard? Are they?

Monads aren't conceptually hard, they're a type class with two methods, but the way they're often taught (via burritos, spacesuits, or category-theory diagrams) makes them seem mystical. The practical truth: you understand monads after writing a few hundred lines of code that uses them (IO actions, Maybe chains, parser combinators). Don't read 'a monad is a monoid in the category of endofunctors' until you've already used them for a month. The learning curve is real, but it's a hill, not a wall.

Introduction

Haskell remains the most influential pure functional programming language in 2026, the one every serious compiler, blockchain VM, or type-system researcher reaches for when correctness matters more than raw speed. Banks like Standard Chartered run trading systems in Haskell, IOG built the Cardano blockchain in it, and GitHub's Semantic code-analysis engine is Haskell from top to bottom.

Interviewing for a Haskell role in India is unusual but premium. Most companies hiring here are FP-shops (Juspay, Tweag), banks (Standard Chartered Bangalore), or blockchain teams (Cardano has a meaningful Indian developer base). Expect deep questions on lazy evaluation, the type system (Hindley-Milner inference), algebraic data types, type classes, and, unavoidably, monads. Senior rounds add monad transformers, STM, GHC extensions, and space-leak debugging.

This guide covers the 40 most-asked Haskell interview questions in 2026, calibrated against GHC 9.10 and the modern ecosystem (Stack/Cabal, Servant, Persistent, Yesod, conduit, aeson, Hedgehog). Questions run basic first, then intermediate, then advanced, and each answer includes the concept, the failure modes that show up in production, and code where it adds clarity.

Ready to practice Haskell interviews?

Don't just read, practice these Haskell questions live with an AI interviewer that asks follow-ups and scores your answers.

AI-powered practice
Instant feedback
Free to start
Start Free Mock Interview