cswork / libraryOpen practice workspace

Articles

Haskell Interview Questions: Laziness, Types, and IO Boundaries

Answer Haskell interview questions with demand traces, WHNF versus normal form, strict folds, explicit failure types, and safe boundaries for lazy file IO.

A Haskell expression can contain an error without immediately failing. An IO String can return before every character has been read. A function can have a plausible type while still crashing on an empty list. You can diagnose these Haskell interview questions by separating what the caller demands, what the type represents, and when an effect actually runs.

This guide uses official Haskell language and library documentation. The traces, parser, and file examples are original practice material, not candidate reports or predictions of an employer's interview.

How should you answer Haskell interview questions?

Start with a concrete observation: does the caller want a constructor, one field, a finite prefix, or the whole result? Then state the type, identify partial operations, and describe the effect boundary. This is our answer method; it is not an official interview scoring rubric.

Suppose an interviewer asks whether a lazy pipeline is efficient. “Haskell evaluates only when needed” is a starting definition, not a complete answer. Identify the consumer, explain which intermediate values remain reachable, and say what you would measure before claiming a memory improvement.

For a short code question, write an expected result and a counterexample before changing implementation. A successful example alone cannot distinguish a total parser from a function that hides error in a branch. Make the empty, invalid, and infinite cases explicit when they are part of the contract.

What does laziness force in this expression?

The Haskell 2010 report describes non-strict semantics: a computation that fails when demanded can inhabit a type. Do not translate that into a promise that every argument is eventually evaluated, or that textual order predicts runtime work.

Consider this original expression:

take 2 (map (+1) [1..])

To display its complete result, the consumer needs two output elements and the end of that two-element result. map supplies values derived from the first two input elements. The result is [2,3]; the consumer does not demand every element of the infinite input.

Change the consumer to length (map (+1) [1..]). It needs an end to the list spine, which this input never provides. That computation does not finish. The important change is the demand imposed by length, not the spelling of map.

Now use length [undefined, undefined]. The finite spine has two cons cells and an empty tail. Counting those cells does not require the element values, so the result is 2. Printing the same list demands the elements and encounters failure.

Keep a demand ledger while you talk through these cases:

Expression observedWhat must be demandedExpected behavior
Display take 2 (map (+1) [1..])Two transformed elements[2,3]
length [undefined, undefined]Finite list spine2
head [7, undefined]First element7
sum [7, undefined]Both numeric elementsFailure

These are semantic predictions, not a memory benchmark. Compiler optimizations can change allocation and evaluation machinery while preserving the observable result. An interview answer should distinguish the semantic explanation from a claim about the generated executable.

What is the difference between WHNF and normal form?

Weak head normal form, or WHNF, exposes the outer shape of a value. Normal form evaluates its substructure as required by the applicable evaluation strategy. The distinction matters when a constructor wraps a deferred computation.

The deepseq documentation explicitly contrasts shallow seq with deeper evaluation. In this original probe, the outer Just constructor is available without evaluating its payload:

(Just undefined :: Maybe Int) `seq` 3

The expression returns 3. Replacing seq with deepseq requires the payload to be evaluated for the usual NFData instance and encounters undefined. Similarly, forcing a pair to WHNF does not necessarily evaluate either component.

This explains why “I made the accumulator strict” can be incomplete. A pair accumulator may expose the pair constructor while its numeric fields still contain deferred additions. Ask whether the accumulator's fields are strict, whether each step forces them, and whether that forcing matches the desired behavior.

Deep evaluation also has limits. Forcing an infinite list to normal form will not terminate. Forcing too early can destroy a useful finite-prefix computation. Choose the boundary deliberately: a bounded completed result is a different contract from a potentially infinite producer.

When should you use foldl, foldl', or foldr?

The official Data.List documentation distinguishes lazy and strict left folds. A lazy foldl can build a deferred accumulator chain; foldl' forces the accumulator at each step to WHNF. That does not automatically mean every nested field is fully evaluated.

For a finite numeric total, compare:

import Data.List (foldl')

total :: [Integer] -> Integer
total = foldl' (+) 0

Explain the choice through the contract: the caller wants one completed total, so retaining a long deferred chain of additions adds no useful streaming behavior. Using Integer also separates this example from fixed-width Int overflow, although arbitrary-precision arithmetic has its own allocation cost.

foldr can be productive on an infinite input when its combining function does not demand the recursive result. For example, foldr (&&) True (False : repeat True) returns False. The first operand allows (&&) to stop before consuming the infinite tail.

Do not say “foldr works on infinite lists” without the qualification. foldr (+) 0 [1..] still requires an unbounded sum. The combining function and the consumer determine whether a useful prefix can emerge.

For a performance follow-up, propose comparing optimized builds on the same finite input, collecting allocation and residency evidence, and checking that outputs still match. Do not infer constant memory from a prime mark alone. Retained inputs, nested structures, and the arithmetic representation can change the result.

What does a type signature prove?

A signature is a constraint on allowed values and operations, not a blanket termination guarantee. a -> a cannot inspect an arbitrary a through type-specific operations, but a definition can still diverge or use undefined. Explain assumptions about totality before making a parametricity argument.

Compare a function type with a typeclass-constrained type. Eq a => a -> a -> Bool permits equality through the Eq dictionary. It does not provide ordering, arithmetic, or a sensible instance by itself. Naming the available operation is more useful than saying “the function supports any type.”

For domain modelling, ask which invalid states can be removed. A quantity should not silently share a type with an arbitrary integer if downstream code relies on it being positive. A smart constructor can validate a raw input, and a module can export the quantity type while hiding its constructor.

That boundary matters: if callers can construct Quantity (-3) directly, the type name has not enforced the invariant. Constructor visibility and validated creation are part of the design, not cosmetic API details.

How should failure stay in the pure core?

Here is an original parser whose failure is ordinary data:

import Text.Read (readMaybe)

data QuantityError = NotAnInteger | NonPositive
  deriving (Eq, Show)
newtype Quantity = Quantity Integer
  deriving (Eq, Show)

parseQuantity :: String -> Either QuantityError Quantity
parseQuantity s =
  case readMaybe s :: Maybe Integer of
    Nothing -> Left NotAnInteger
    Just n | n <= 0 -> Left NonPositive
           | otherwise -> Right (Quantity n)

The Text.Read reference documents readMaybe. Our extra positive-quantity rule is a domain decision. "12" yields a validated quantity, "0" yields NonPositive, and "twelve" yields NotAnInteger.

Maybe is suitable when the caller only needs success or absence. Either carries a reason that can guide a message or another decision. Neither wrapper makes an arbitrary implementation total: putting error inside a Right would still leave a deferred failure.

Use traverse parseQuantity when the desired contract is to parse a collection and stop at a failure through Either. If every invalid row must be reported, say that explicitly and choose an accumulation design. A first-error parser and a complete validation report answer different requirements.

Rehearse the change from “positive integer” to “positive integer within a configured maximum, such as 100.” Decide whether the maximum belongs in the function input, and ensure the test suite covers values on either side. Avoid reading mutable configuration invisibly inside the pure parser.

Why does IO describe a boundary rather than a returned string?

An IO String is an action that can produce a string when executed. It is not interchangeable with String. Binding an action to a name does not perform it; sequencing it within the program's executed IO action does.

For this parser, the shell can read a line, call the pure function, and render the result. That separation lets you test invalid input without reading from a terminal. The shell owns user interaction; the parser can therefore be tested with plain strings and expected failure values.

Distinguish expected validation failure from an exceptional I/O condition. An unreadable file is not the same as a readable file containing "zero". Catching every exception and returning NonPositive would discard that distinction and make diagnosis misleading.

An interview follow-up may ask about retrying. Retrying a pure conversion on identical input does not fix invalid text. Retrying an external operation may be useful under a defined transient-failure policy. Name the operation, the retry limit, and any repeated side effect before adding a wrapper.

How can lazy IO escape a file's lifetime?

The official System.IO reference documents withFile and lazy contents. Returning a lazy string from a bracketed file scope can leave later demand trying to read a handle that has already closed.

For a small bounded file, this original pattern forces the characters while the handle remains available:

import Control.DeepSeq (force)
import Control.Exception (evaluate)
import System.IO (IOMode(ReadMode), hGetContents, withFile)

readFully :: FilePath -> IO String
readFully path = withFile path ReadMode $ \h -> do
  contents <- hGetContents h
  evaluate (force contents)

The forcing occurs inside the resource scope. The bracketed lifetime also handles cleanup when reading or evaluation raises an exception. This is not a recommendation to load unbounded files into memory; a large-file contract may need explicit chunked streaming and bounded processing.

Explain where the exception is observed, how much input is retained, and who closes the resource. Those three questions expose mistakes that an answer about “pure versus impure functions” can miss.

Practice five transferable questions in Haskell

In particular, a Python generator is not a semantic substitute for a non-strict Haskell list.

QuestionFollow-up to rehearse
Implement a Safe Average Function in PythonModel the empty-input policy with an explicit type.
Toy Language Types: Render Types and Signatures, Infer Generic Return TypesInfer and explain type constraints before implementation.
Function Signature Lookup with Exact and Variadic Parameter MatchingSeparate signature matching from effectful lookup.
Python and pytest Fundamentals: Fixtures, Decorators, and GeneratorsContrast a Python generator with Haskell demand semantics.
Implement retry wrapper and interdependent validatorsKeep validation pure; retry only the external action.

Choose one coding and algorithms question, write a Haskell solution, and add a demand ledger plus an explicit failure type. Your explanation should identify one changed consumer or input that changes termination, failure, or retained memory.

Sources and Further Reading

Research checked October 5, 2026. Examples were reviewed against official semantics; no GHC compiler run or memory benchmark is claimed.