λ Scala → Haskell · ch.8 · laziness
🦥 Chapter 8 · Evaluate on demand

Nothing happens until you look.

Haskell hands you an unevaluated "I owe you a value" — a thunk — and only forces it when something actually needs it. That's why infinite lists are ordinary, and why the occasional space leak sneaks in. Scala's lazy val / LazyList is the opt-in version of this default.

In Scala, evaluation is eager by default and lazy when you ask. You write val x = expensive() and it runs now; you write lazy val x = expensive() and it waits. Haskell flips the default: everything waits. An expression isn't a value — it's a recipe for one, held until somebody demands the result.

That single design choice ripples through the whole language. This chapter walks it end to end:

  • Thunks & WHNF — what "deferred" really means, and how far forcing actually goes.
  • Infinite structures[1..], repeat, cycle, and the self-referential fibs.
  • Stopping early — why take, foldr, and && consume only what they need.
  • Space leaks & the cure — thunk pileups, and foldl' / seq / $! / bang patterns.

1Lazy by default

Every expression in Haskell starts life as a thunk — a deferred computation, a little promise that says "I'll be a value when you need me." Nothing inside it runs until something forces it.

You already know this mechanism from Scala; you just had to opt in. Compare:

// Scala — eager unless you say otherwise
val a = expensive()      // runs NOW
lazy val b = expensive() // waits until first use

In Haskell there's no keyword. let a = expensive () already behaves like Scala's lazy val: the work is parked until a is demanded — and if it never is, it never runs.

This is why a function can take an argument it never looks at and that argument's value is never computed — not even if computing it would loop forever or throw. The classic const x _ = x can be handed undefined as its second argument and shrug it off.

💡
Mental model: a Haskell value is Scala's lazy val everywhere, plus automatic memoization. Once a thunk is forced, its result is cached — force it again and you get the stored value, not a re-run.

2WHNF vs NF — how far does forcing go?

Here's the part that trips people up: forcing a value usually does not evaluate it all the way down. It stops at the outermost constructor — a state called weak head normal form, or WHNF.

Take the pair (1+1, 2+2). Force it to WHNF and you learn one thing: it's a pair, (_, _). The tuple constructor is exposed — but its two slots are still unevaluated thunks. Nobody added anything yet.

  • WHNF — peeled to the top constructor only. (⟨1+1⟩, ⟨2+2⟩): the pair exists, insides deferred. A list forced to WHNF tells you only whether it's [] or (x : xs) — nothing about x or how long it is.
  • NF (normal form) — fully evaluated, no thunks left anywhere. (2, 4).

Most of the time WHNF is all the demand that propagates — pattern-matching on a constructor forces just enough to see which constructor it is. Step it through below.

Interactive · force to WHNF, then NF One step reveals the outermost constructor
Expression:
all thunks — nothing forced yet

3Infinite structures, no problem

Once values are deferred, an infinite list stops being a paradox. It's just a list whose tail is a thunk that, when forced, produces one more element and another thunk. You only ever build the prefix you actually pull on.

  • [1..] — every integer from 1 upward. Scala's LazyList.from(1).
  • repeat 0[0, 0, 0, …] forever.
  • cycle [1,2,3][1,2,3,1,2,3,…], the list looped end to end.

The showstopper is the self-referential definition of the Fibonacci numbers:

fibs = 0 : 1 : zipWith (+) fibs (tail fibs)

Read it slowly. fibs starts 0, 1, …, and each later element is the sum of the two before it — computed by zipping fibs against its own tail. It refers to itself while being defined, and that only works because the tail is lazy: each element is forced into existence exactly when something asks for it. Pull on it below.

Interactive · pull on infinite fibs Each pull forces one more element into existence
take 0 fibs ≡ []

4Why take and foldr can stop early

If [1..] is infinite, how does take 5 [1..] ever return? Because demand flows backwards: take 5 asks for exactly five elements, so exactly five thunks get forced. The rest of the infinite list is never built.

take 5 (map expensive [1..])  -- only maps over 5 elements

The same logic powers short-circuiting. In Haskell && and || are ordinary functions, lazy in their second argument — and that falls out of laziness, you don't need special syntax for it:

False && undefined  -- => False, never touches undefined
True  || undefined  -- => True,  same deal

And foldr can quit before consuming its whole input. Because it associates to the right, the combining function gets the rest of the fold as a lazy argument — so if it ignores that argument, the fold stops:

any p = foldr (\x acc -> p x || acc) False
any even [1..]  -- => True, stops at the first 2

That's the lazy advantage in miniature: foldr over an infinite list can terminate, as long as the consumer stops demanding.

💡
Rule of thumb: laziness lets you separate the producer from the consumer. The producer can be infinite; the consumer decides how much of it ever exists.

5The dark side: space leaks

Laziness has a bill, and it arrives as memory. If you keep building thunks without ever forcing them, they pile up — a chain of deferred work that quietly eats the heap. That's a space leak.

The textbook example is a strict-looking sum that isn't:

foldl (+) 0 [1..10000000]  -- builds a giant thunk, may blow the stack

You'd expect this to tick along holding a single running total. It doesn't. Lazy foldl never forces the accumulator as it goes, so instead of a number it grows a nested expression: ((((0+1)+2)+3)+…), ten million layers deep. Only when the final result is demanded does that whole tower collapse — and forcing it can overflow the stack or exhaust memory first.

The accumulator looks like a number, but until something forces it, it's a tree of deferred additions. Watch the chain grow below.

Interactive · foldl vs foldl' thunk buildup Step both; compare the peak thunk count
lazy foldl (+) 0
0
thunks held: 0
strict foldl' (+) 0
0
thunks held: 0
folding [1,2,3,4,5] — step to begin
⚠️
The leak hides in plain sight. The lazy column's accumulator is a single value conceptually, but in memory it's the whole unevaluated chain. The strict column collapses each step to a plain number, so it never holds more than one thunk. Same answer, wildly different memory profile.

6Getting strict on purpose

The fix is to force the accumulator as you go, so each step collapses to a value instead of stacking another thunk. Haskell gives you several tools for that — each one a way to say "evaluate this to WHNF, now."

  • foldl' — the strict left fold, from Data.List. It forces the accumulator to WHNF at every step. This is the one-line cure for the foldl leak — reach for it by default.
  • seq a b — forces a to WHNF before returning b. It's how you say "make sure this is evaluated before moving on."
  • f $! x — strict application: force x to WHNF, then apply f. Just seq wearing an operator hat: f $! x = x `seq` f x.
  • Bang patterns — with the BangPatterns extension, !x in a pattern forces x when the pattern matches. Common in tight accumulator loops: go !acc (x:xs) = go (acc+x) xs.

Here's the strict accumulator written by hand, the bang-pattern way:

sumStrict = go 0
  where go !acc []     = acc
       go !acc (x:xs) = go (acc + x) xs

When to force? Not everywhere — premature strictness throws away the benefits laziness gives you. The practical rule: force the accumulator of a left fold or loop, and otherwise leave things lazy until a profiler tells you a thunk is piling up.

⚠️
seq only forces to WHNF, not all the way down. seq (1+1, 2+2) () forces the pair to (_, _) but leaves the additions as thunks. To force deeply you need deepseq (from the deepseq package) — but you rarely want it.

7When laziness shines

It's easy to read sections 5 and 6 and conclude laziness is a liability. It isn't — it's the thing that makes a lot of clean Haskell possible. Three places it pays off:

  • Roll your own control structures. In Scala, a custom when(cond)(body) evaluates body eagerly unless you mark the parameter => Boolean (by-name). In Haskell every argument is by-name for free, so you can define if-like and looping combinators as plain functions, no macro or by-name annotation needed.
  • Producer / consumer, decoupled. Generate an infinite stream of candidates, primes, or game states, and let a separate consumer decide how far to go. take 10 primes needs no shared bound between the two halves — the consumer's demand is the bound.
  • Pay only for what you use. Pipelines fuse: take 3 . filter even . map (*2) $ [1..] walks the list once and stops after three results. No intermediate full lists are ever built, even though each stage reads like it processes everything.

The trade is real but learnable: laziness buys you composition and infinite data; you pay it back with the occasional foldl'. Knowing which way demand flows is the whole skill.

8The same idea, in both languages

Scala's LazyList and lazy val are the opt-in version of Haskell's everyday default. Side by side, the infinite naturals and the self-referential fibs:

9Check yourself

3 questions · instant feedback 0 / 3

10Where this goes next

You can now read demand the way a Haskell compiler does: thunks until forced, WHNF as the usual depth, infinite data as routine, and foldl' when the thunks pile up. Next we stack effects — combining Reader, Except, and IO into one workable monad without drowning in boilerplate.

🥞
Up next · Chapter 9
Monad Transformers
Stacking effects — Reader + Except + IO — without the boilerplate.