Lazy Evaluation

Most code is eager: the moment you write down an expression, the machine computes it. Lazy evaluation flips that. A lazy value is a promise to compute later — nothing actually runs until someone genuinely needs the result. "Don't do the work until you have to, and never do it twice."

That sounds like a micro-optimisation, but it unlocks something remarkable: you can describe infinite data — all the natural numbers, an endless stream of random events, the entire Fibonacci sequence — and it costs nothing, because only the pieces you actually ask for are ever built.

The thunk: a computation in a box

The basic tool is a thunk — a zero-argument function wrapping an expression. Writing () => expensive() doesn't run expensive(); it packages it. The work happens only when you force the thunk by calling it. Watch the log order: the message prints only when we force.

function slowSquare(n: number): number { console.log("...computing square of " + n); return n * n; } // eager: runs right now const eager = slowSquare(4); console.log("made eager value"); // lazy: a thunk — nothing runs yet const lazy = () => slowSquare(9); console.log("made lazy thunk (nothing computed)"); console.log("forcing lazy:", lazy()); // NOW it computes

The eager square computed before "made eager value" printed; the lazy one waited until we called lazy(). A thunk is just deferral, but deferral is the whole idea.

Compute once, remember: memoised thunks

Forcing a thunk twice repeats the work. The lazy motto's second half — "never twice" — is fixed by memoising: compute on first force, cache the result, hand back the cache afterward. A closure holds the cache privately.

function lazyOnce<T>(compute: () => T): () => T { let done = false; let value: T; return () => { if (!done) { value = compute(); done = true; } return value; }; } const x = lazyOnce(() => { console.log("working hard..."); return 6 * 7; }); console.log("before forcing"); console.log(x()); // "working hard..." then 42 console.log(x()); // 42 — no recompute, cache hit

"working hard..." prints exactly once, no matter how often we force. That memoised thunk is the building block of a lazy stream.

Infinite streams

Now the payoff. A stream is a lazy list: a head value plus a thunk for the rest. Because the tail is deferred, the list can be endless — we only ever force as far as we walk. Here is the stream of all natural numbers, and a lazy take that pulls out the first few.

interface Stream { head: number; tail: () => Stream; // the rest — a thunk, so it can be infinite } // the infinite stream n, n+1, n+2, ... function from(n: number): Stream { return { head: n, tail: () => from(n + 1) }; } function take(s: Stream, k: number): number[] { if (k === 0) return []; return [s.head, ...take(s.tail(), k - 1)]; // force just k tails } const naturals = from(1); // "all" the counting numbers — built lazily console.log(take(naturals, 5)); // [1, 2, 3, 4, 5] console.log(take(from(100), 3)); // [100, 101, 102]

naturals is conceptually infinite, yet the program finishes instantly, because take forces only five tails and no more. You described an endless object and used a finite slice of it — the essence of laziness.

Streams let you write a data definition and read off as much as you want. A lazy map over a stream transforms each element on demand; combine that with recursion and you get famous one-liners — the Fibonacci sequence defined in terms of itself, or the Sieve of Eratosthenes as "the head is prime, filter it out of the tail, repeat." In a lazy language like Haskell you can literally write primes = sieve [2..] over an infinite list. The machinery is exactly the head-plus-thunk stream above; only the elements you print are ever computed.

Why laziness earns its keep

Laziness has sharp edges: