Files
functional-programming-snake/course/07-the-io-type.md
T
2026-10-29 01:00:23 +01:00

39 KiB
Raw Blame History

Module 7 — The IO type

Time: ~5 h (take a break halfway; this is the deepest module of the course) · You'll edit: JS js/src/io.js · Scala scala/src/IO.scala · F# fsharp/src/IO.fs Tests: JS cd js && node --test "test/m07-*.test.js" · Scala cd scala && ./test.sh M07 · F# cd fsharp && ./test.sh M07 Prerequisites: Modules 0–6, especially Module 4 (the state-passing pattern, pure/map/flatMap on Random) and Module 6 (Scene)

Why this module

Here is a question that sounds like a contradiction: what does it mean for a program that draws on the screen to be pure?

A pure function, as you saw in Module 1, gives the same result for the same arguments and does nothing else. Drawing is "something else". If drawSquare() paints a square, then calling it twice paints twice; you can't replace the call with its result (undefined) without changing what the program does. Asking the browser for a random seed is worse: the same call gives a different answer each time. So it seems a game must be impure somewhere in the middle, wherever it draws or asks the outside world for something.

Module 6 already showed the way out for one half of the problem. view doesn't draw; it returns a Scene, a description of a drawing. This module takes that idea to its conclusion: every effect becomes a value that describes it. "Draw this rectangle" becomes a value. "Ask for a random seed, and then, with the answer, do this" becomes a value. A whole program becomes a value. Building those values is pure. Something, at the very edge, still has to perform the description — but that will be one small function (Module 9), and everything you write before it stays pure and testable.

The type of such descriptions is called IO. You will build it from scratch, understand why it has exactly the shape it has, write a pure interpreter for it so you can test programs without a screen, and check that it obeys the three laws that make it a monad.

Learning objectives

By the end of this module you can:

  • explain the difference between performing an effect and describing it, and why descriptions keep a program referentially transparent;
  • explain why an IO needs a continuation k and cannot be a plain list of operations;
  • implement pure, effect, map, flatMap, sequence, draw, randomSeed, drawScene and a pure interpreter trace;
  • state the three monad laws and explain how the tests check them using an interpreter;
  • predict when a function given to map/flatMap runs: when the program is built, or when it is interpreted;
  • compare our IO with Haskell's IO, cats-effect, ZIO and free monads.

Concepts

1. Performing versus describing

Recall referential transparency from Module 1: an expression is referentially transparent when you can replace it by its value without changing the program. Try it on these two snippets:

const x = Math.random()
const pair1 = [x, x]

const pair2 = [Math.random(), Math.random()]

If Math.random() were a value you could substitute, pair1 and pair2 would be the same. They are not: pair1 has two equal numbers, pair2 almost surely two different ones. Math.random() is not an expression that has a value; it is an action that produces one when performed.

Now compare a different design. Suppose randomSeed is not an action but a value that describes the action "ask the outside world for a seed":

const randomSeed = { tag: 'Effect', op: { tag: 'RandomSeed' }, /* ... we'll see what goes here ... */ }
const program1 = [randomSeed, randomSeed]

This randomSeed is a plain object. Writing it twice or naming it once makes no difference at all — you can substitute freely. It does nothing until someone interprets it. That is the trick of the whole module, and it is also, essentially, how Haskell has been pure since the 1990s (A Gentle Introduction to Haskell: Input/Output).

You saw the same move twice already:

Module The "doing" version The "describing" version
4 Math.random() mutates a hidden seed Random<A> = seed → [A, seed], a function you can pass around
6 draw(ctx, state) paints view(state) returns a Scene
7 ctx.fillRect(...), crypto.getRandomValues(...) IO<A>, a value describing a program that produces an A

2. First attempt: a program is a list of operations

Let us design the description step by step. The simplest idea: a program is a list of operations (we'll call them Ops), and an interpreter performs them one after another. We need two operations for the whole course:

JavaScript

// An Op is plain data:
//   { tag: 'Draw', shape }     draw a Rect or Text from Module 6   (answer: nothing, undefined)
//   { tag: 'RandomSeed' }      ask the outside world for a seed    (answer: a uint32 number)

Scala

// Already in scala/src/IO.scala
enum Op:
  case Draw(shape: Shape)
  case RandomSeed

enum OpResult:          // what the outside world answers
  case Done             // ... to a Draw
  case Seed(value: Long)  // ... to a RandomSeed

F#

// Already in fsharp/src/IO.fs
type Op =
    | Draw of Shape
    | RandomSeed

type OpResult =
    | Done
    | Seed of uint32

From Scala: notice that Scala and F# need an explicit OpResult type, because an interpreter must hand back some typed answer for any Op. JS is untyped, so the answer to Draw is just undefined and the answer to RandomSeed is just a number.

A list-of-ops program and its interpreter would look like this (illustration only, not part of the exercises):

// A program as a list of ops, and an interpreter that performs them.
const program = [{ tag: 'Draw', shape: background }, { tag: 'Draw', shape: score }]

const runAll = (ops, platform) => ops.forEach((op) => perform(op, platform))

For drawing a scene, this is enough: drawScene would be scene.map(shape => ({tag:'Draw', shape})). But now try to write this program:

Ask for a random seed. Then start a new game with that seed, and draw it.

The shapes to draw depend on the seed, and the seed only exists after the first operation has been performed. A list must be complete before anybody performs it, so there is nowhere to put "and then, with the answer, decide what to do next". The list-of-ops design can describe programs whose steps are fixed in advance, and nothing else.

3. Second attempt: an operation and a continuation

The fix is to pair each operation with a function that receives the answer and returns the rest of the program:

Effect( op = RandomSeed,
        k  = seed => <the rest of the program, which may use seed> )

That function is called the continuation, traditionally named k. "Continuation" just means "what to do next"; you have used them every time you passed a callback (button.onclick = () => ...), and the idea has a long history under the name continuation-passing style.

When the program is finished there is no operation left, only a final value. So a program is one of two things:

  • Pure(value) — done; the program's result is value;
  • Effect(op, k) — perform op; give its answer to k; k returns the rest of the program (another IO).

That's the whole type. Here it is in the three tracks (this definition is given to you in the stub files):

JavaScript

// IO<A> =
//   { tag: 'Pure',   value }      value : A
//   { tag: 'Effect', op, k }      op : Op,  k : (answer) => IO<A>

Scala

enum IO[+A]:
  case Pure(a: A)
  case Effect(op: Op, k: OpResult => IO[A])

F#

type IO<'a> =
    | Pure of 'a
    | Effect of Op * (OpResult -> IO<'a>)

From Scala: this is a recursive algebraic data type, like List (Nil | Cons(head, tail)). The difference is that the "tail" is hidden behind a function: you can only get the rest of the program by supplying an answer. A list is a chain of values; an IO is a chain of questions.

Let us write the program from the previous section by hand, with no helpers at all. (JS shown; it is the same tree in every language.)

// "Ask for a seed; then draw a square whose x depends on the seed; then finish with the seed."
const program = {
  tag: 'Effect',
  op: { tag: 'RandomSeed' },
  k: (seed) => ({
    tag: 'Effect',
    op: { tag: 'Draw', shape: { tag: 'Rect', x: seed % 20, y: 0, w: 1, h: 1, color: '#ffffff' } },
    k: (_) => ({ tag: 'Pure', value: seed }),
  }),
}

Drawn as a picture, the program is a chain where each link waits for an answer:

 Effect(RandomSeed) ──answer: seed──▶ Effect(Draw(Rect at x = seed % 20)) ──answer: (none)──▶ Pure(seed)

Stop and check two things:

  1. Building program performed nothing. No seed was asked for; no square was drawn. program is an object, and the functions inside it have not been called.
  2. The second operation depends on the first answer. That dependency is written as an ordinary JS function. This is the expressive power the list did not have.

4. Interpreting a program by hand

An interpreter is a function that walks this structure: if it sees Pure, it's done; if it sees Effect(op, k), it performs op somehow, calls k with the answer, and continues with whatever k returns.

The interesting phrase is "performs op somehow". Who decides what "performing" means? The interpreter does. That gives us the freedom to write two very different interpreters for the same programs:

  • trace(io, seed) (this module): a pure interpreter for tests. It doesn't draw anything. It answers every RandomSeed with the fixed seed you give it, answers every Draw with "done", and records the list of operations it saw. It returns { result, ops }.
  • runIO(io, platform) (Module 9): the real interpreter, which calls the canvas and the browser's random generator.

Let us run trace(program, 7) by hand. Fill in the table yourself before reading the last row:

Step Current program Operation performed Answer given ops so far
1 Effect(RandomSeed, k1) RandomSeed 7 [RandomSeed]
2 k1(7) = Effect(Draw(Rect x=7), k2) Draw(Rect x=7) undefined (Scala/F#: Done) [RandomSeed, Draw(Rect x=7)]
3 k2(undefined) = Pure(7) — — finished: { result: 7, ops: [RandomSeed, Draw(Rect x=7)] }

Now ask: is trace a pure function? Same io, same seed → same {result, ops}, every time; nothing outside is touched. Yes. So programs that "use randomness and draw on the screen" can be tested with deepStrictEqual, the same way you have tested everything so far. This is the single most important consequence of the module.

From Scala: an interpreter is a fold over a data structure, like foldRight over a List, except that to get from one node to the next you must supply an answer. You will write trace by recursion (no loops, no mutation): the ops of Effect(op, k) are op followed by the ops of whatever k(answer) traces to.

5. Writing programs comfortably: pure and effect

Nobody wants to write nested objects like program above. We need small building blocks and ways to glue them. The two simplest:

  • pure(a) — the program that does nothing and produces a. It is just Pure(a).
  • effect(op) — the program that performs exactly one op and produces its answer. What should its continuation be? It must take the answer and return "the rest of the program", and the rest of the program is "nothing more; the result is the answer". Which of our building blocks is exactly "take a value, return a finished program with that value"? (The docs in the stub say it outright: Effect(op, pure).)

With effect you can build the two operations the game needs:

  • draw(shape) — one Draw operation. In JS the answer is already undefined, so draw(shape) can be effect(...) directly. In Scala/F# effect gives an IO[OpResult], but draw promises IO[Unit]: you'll need to turn the Done into ().
  • randomSeed — one RandomSeed operation, producing the seed. In Scala/F# you must turn Seed(value) into value. What about Done? An interpreter that answers a RandomSeed with Done is broken; the honest thing to do is fail loudly (throw an exception with a clear message). That case cannot happen with a correct interpreter.

Notice that randomSeed is a value, not a function. In JS you write randomSeed, not randomSeed(). Why does that make sense? Because the description "ask for a seed" is always the same description. Using it twice in a program asks twice when interpreted, exactly as you would expect — but the value itself never changes. (In the JS stub it is a strange-looking object with a getter, only so that the stub fails loudly; replace it with a plain value.)

6. map: changing the result without running anything

Suppose you have randomSeed : IO<uint32> and you want IO<GameState>: "ask for a seed, and the result is newGame(seed)". That's map(randomSeed, newGame).

What must map(io, f) do? Think case by case, and think about when f may run:

  • If io is Pure(a), the value is right there. The result is a finished program whose value is f(a).
  • If io is Effect(op, k), there is no value yet. It will only exist when an interpreter performs op and calls k. So map can't call f now. The new program must perform the same op, and its continuation must take the answer, compute the rest of the original program with k, and then apply f to whatever that rest eventually produces. That last part is itself a map — on a smaller program.

That second case is the heart of the matter: map doesn't transform a value; it builds a new description that will transform the value once it exists. The tests check this directly:

test('m07 building map(io, throwingF) does not throw', ...)   // building must not call f
test('m07 tracing map(io, throwingF) runs f and throws', ...)  // interpreting must call f

From Scala: you know Option.map and List.map: they apply f to the value(s) inside. Future.map is the closer analogue: it can't apply f until the value arrives, so it registers f to run later. Our map does the same thing with plain data instead of threads.

Language note. JS functions are data-first, map(io, f). Scala uses a method, io.map(f). F# uses a data-last module function, IO.map f io, so it works with the pipe: io |> IO.map f.

7. flatMap: "and then"

map lets the result depend on an earlier answer. But we need more: the next operations must depend on an earlier answer ("ask for a seed, then draw the game made from it"). That's flatMap(io, f), where f returns not a value but another program:

flatMap(io: IO<A>, f: A → IO<B>) : IO<B>

Read it as "run io; with its result a, continue with the program f(a)". It's the semicolon of our little language. Again, case by case:

  • Pure(a): the first program is already finished with a, so the combined program is f(a).
  • Effect(op, k): perform the same op; the new continuation takes the answer, gets the rest of the original program from k, and then continues that rest with f. Again, the "and then continue with f" is itself a flatMap on a smaller program.

A picture helps. flatMap takes the chain of io and grafts f onto its end — at the place where io would have said Pure(a):

io:               Effect(op1) ─▶ Effect(op2) ─▶ Pure(a)
flatMap(io, f):   Effect(op1) ─▶ Effect(op2) ─▶ f(a) = Effect(op3) ─▶ Pure(b)

Two observations to make you comfortable:

  1. Once you have flatMap and pure, you get map for free: "transform the result with f" is the same as "and then finish with f(a)". Can you write map in one line using flatMap and pure? (This is a classic exercise; it is also a nice way to check your flatMap.)
  2. You have seen this exact pair before. In Module 4, Random<A> had pure, map and flatMap with the same shapes and the same meaning ("then, with the value, continue"). This is not a coincidence; it's a pattern with a name, which you will meet properly in section 10.

From Scala: flatMap is the method that for-comprehensions desugar to, and you've used it on List, Option, Either and Future. On List it means "for each element, a list; concatenate"; on Option "if there is a value, continue; otherwise stop"; on IO it means "when the answer arrives, continue with this program".

Language note. In F# this function is called bind (IO.bind f io), which is the traditional name in ML and Haskell (>>=). Same function, different name.

8. sequence and drawScene

To draw a whole scene we need to run many IO<unit> programs one after another. sequence(ios) builds that combined program, left to right; sequence([]) is a program that does nothing and produces unit. Then

drawScene(scene) = sequence(scene.map(draw))

and every Scene from Module 6 becomes a program that draws it. trace(drawScene(view(s)), 0).ops is then exactly view(s) wrapped in Draw ops — which is what Module 9's render test checks.

How should you build sequence? It's a fold: combine programs pairwise with flatMap ("run this one, then the rest"). There are two ways to fold, and they produce differently shaped programs:

right-nested:  flatMap(io1, _ => flatMap(io2, _ => flatMap(io3, _ => pure())))
left-nested:   flatMap(flatMap(flatMap(pure(), _ => io1), _ => io2), _ => io3)

Both mean the same thing (the associativity law below guarantees it), and both pass this module's tests. But they don't cost the same. Keep the question "which one should I build?" in mind; section 12 answers it, and Module 9 has a test that cares.

9. When does f run? An honest note about eagerness

You should now be able to predict exactly when the function you pass to flatMap runs. Look at the Pure case again: flatMap(Pure(a), f) is f(a) — and our implementation computes f(a) right away, while building the program, not later during interpretation.

Is that a problem? In a pure world, no. If f is pure, calling it now or later gives the same result and has no other effect; nobody can tell the difference. Building flatMap(pure(3), n => draw(...)) doesn't draw anything either way, because f only returns a description of drawing.

There are exactly two ways to notice the difference, and you should know them:

  • Exceptions. flatMap(pure(1), _ => { throw new Error('boom') }) throws while building, whereas flatMap(randomSeed, _ => { throw ... }) only throws when interpreted.
  • Non-termination or very expensive work. If f loops forever or takes a minute, building the program hangs or takes a minute.

Both are, in a sense, impurities in f, and our fs won't have them. Production libraries do care: cats-effect, ZIO and Haskell's IO add a case like Suspend(() => io) / Delay so that even the first step can be postponed until interpretation. Our tiny IO doesn't need it, and it is good to understand why it doesn't.

10. The monad laws

A type with pure and flatMap that satisfies three equations is called a monad. The equations are not bureaucracy; each one is a refactoring you want to be allowed to do without changing what your program does.

Write ≡ for "behaves the same". For any value a, any program m, and any functions f, g that return programs:

  1. Left identity: flatMap(pure(a), f) ≡ f(a) "Wrapping a value and immediately continuing with it is the same as continuing with it directly."
  2. Right identity: flatMap(m, pure) ≡ m "Continuing with 'return it unchanged' adds nothing."
  3. Associativity: flatMap(flatMap(m, f), g) ≡ flatMap(m, x => flatMap(f(x), g)) "It doesn't matter how you group a chain of 'and then's." This is what lets you extract any part of a sequence into a helper function.

In Scala and F# syntax:

Scala

IO.pure(a).flatMap(f)        ≡ f(a)
m.flatMap(IO.pure)           ≡ m
m.flatMap(f).flatMap(g)      ≡ m.flatMap(x => f(x).flatMap(g))

F#

IO.pure a |> IO.bind f                   ≡ f a
m |> IO.bind IO.pure                     ≡ m
m |> IO.bind f |> IO.bind g              ≡ m |> IO.bind (fun x -> f x |> IO.bind g)

How can a test check "behaves the same"? You can't compare two IO values with ==: they contain functions, and functions can't be compared. But you can compare what they do under an interpreter! Two programs that produce the same result and perform the same operations, under the same answers, are indistinguishable to anyone using them. So the tests check:

assert.deepStrictEqual(trace(flatMap(pure(a), f), 5), trace(f(a), 5))

for a few programs (pure(4), randomSeed, and a draw followed by randomSeed). This is called observational equality: equal as far as any observer (here: the interpreter) can tell. It is also a lesson in why the pure interpreter is valuable: without it, you could not even state these tests.

From Scala: Option, List, Either and Future all have pure-like constructors (Some, List(_), Right, Future.successful) and flatMap, and they all obey these laws. The for syntax relies on them: the compiler rewrites for into nested flatMaps in one particular grouping, and associativity is what makes that grouping irrelevant.

11. Syntax that hides the plumbing

Nested flatMaps become hard to read quickly. Scala and F# offer syntax for exactly this pattern; JS doesn't (but see the note).

Scala — because IO has map and flatMap, it works in a for-comprehension with no extra work:

val program: IO[Long] =
  for
    seed <- IO.randomSeed
    _    <- IO.drawScene(View.view(Spawn.newGame(seed)))
  yield seed
// desugars to: IO.randomSeed.flatMap(seed => IO.drawScene(...).map(_ => seed))

F# — F# has computation expressions. You enable them by writing a small builder object that tells the compiler which functions mean "bind" and "return". This is optional and not tested; try it after the exercises:

type IOBuilder() =
    member _.Bind(io, f) = IO.bind f io
    member _.Return(a) = IO.pure a
    member _.ReturnFrom(io) = io

let io = IOBuilder()

let program : IO<uint32> =
    io {
        let! seed = IO.randomSeed
        do! IO.drawScene (View.view (Spawn.newGame seed))
        return seed
    }

JavaScript has no such syntax. (Generators can imitate it — libraries such as redux-saga do this — but that's beyond our scope.) We'll keep our JS programs small enough that plain flatMap reads well.

Read the two snippets above and say what they would do if interpreted by trace(…, 42): what is the result, and what is the first element of ops?

12. Stack safety (why the shape of the tree matters)

Our IO is made of nested functions, and calling nested functions uses the call stack. Two places can blow the stack if a program is very long (tens of thousands of operations):

  • The interpreter. trace is written by recursion, one level per operation. That's fine for tests (a few hundred ops). The real interpreter in Module 9, runIO, must handle a full game for hours and is written as a loop instead (or, in Scala, a @tailrec function, which the compiler turns into a loop).
  • The continuations themselves. Look again at what flatMap does to an Effect: its new continuation calls the old k and then calls flatMap again on the result. If you nest flatMap on the left — flatMap(flatMap(flatMap(…))) — then the first continuation, when called, must unwind every one of those levels at once before it can return the next step. A left-nested chain of 100 000 flatMaps overflows the stack even if the interpreter is a loop.

That is the answer to the question from section 8: build sequence right-nested (fold from the right), so each continuation does a constant amount of work before handing back the next Effect. Module 9 has a test that runs sequence of 100 000 draws through runIO; a left-nested sequence will fail it with a stack overflow.

Production libraries solve this once and for all with a technique called trampolining: they represent flatMap itself as a data constructor (FlatMap(io, f)) and the interpreter re-associates left-nested chains on the fly, in a loop, so no shape of program can overflow the stack. Rúnar Bjarnason's paper "Stackless Scala With Free Monads" explains it beautifully; see also Trampoline (computing). We keep the simpler representation and the rule "fold right".

13. What we have built, and its family name

Step back and look at the type: a program is either done, or one instruction plus a function from the instruction's answer to the rest of the program. The instructions (Op) are a separate, ordinary data type; the IO structure doesn't care what they are. This shape is known as a free monad (more precisely, a close cousin called the "operational" or "freer" monad): it gives you pure/flatMap and the monad laws for free on top of any set of instructions, and leaves the meaning of the instructions entirely to the interpreter. Gabriel Gonzalez's "You Could Have Invented Free Monads" tells the same story from Haskell.

Consequences you will use in the rest of the course:

  • Adding an effect is adding an Op. Want to play a sound, or save a high score? Add {tag:'PlaySound', name} or {tag:'Save', key, value} and teach the interpreters what to do with it (you'll think through exactly this in Module 10).
  • Many interpreters. trace for tests, runIO for the browser, and you could write a third that logs every op, or one that answers RandomSeed from a recorded file to replay a bug.

Exercises

All exercises are in the one file per track. Write them in the order below; each one uses the previous ones. Remember the purity rules: no let, no loops with mutation, no push in JS; no var/mutable. (trace is recursive.)

Exercise 7.1 — pure and effect

JavaScript

export const pure = (a) => /* IO<A> */
export const effect = (op) => /* IO<answer>: Effect(op, pure) */

Scala

object IO:
  def pure[A](a: A): IO[A] = ???
  def effect(op: Op): IO[OpResult] = ???

F#

module IO =
    let pure (a: 'a) : IO<'a> = failwith "TODO m07"
    let effect (op: Op) : IO<OpResult> = failwith "TODO m07"

Proved by: effect(op) is an Effect whose continuation is pure (it calls the continuation with an answer and compares with pure(answer)).

Exercise 7.2 — map and flatMap

JavaScript

export const map = (io, f) => /* IO<B> */
export const flatMap = (io, f) => /* IO<B> */

Scala (methods on the IO enum)

enum IO[+A]:
  ...
  def map[B](f: A => B): IO[B] = ???
  def flatMap[B](f: A => IO[B]): IO[B] = ???

F# (data-last; bind is F#'s name for flatMap)

let map (f: 'a -> 'b) (io: IO<'a>) : IO<'b> = failwith "TODO m07"
let bind (f: 'a -> IO<'b>) (io: IO<'a>) : IO<'b> = failwith "TODO m07"

Proved by: map transforms the result without adding ops, flatMap passes the result of the first IO to the second, building map(io, throwingF) does not throw, tracing map(io, throwingF) runs f and throws, and the nine monad-law tests (left identity holds for …, right identity holds for …, associativity holds for …). The law tests need trace (7.4) to run, so expect them to stay red until then.

Watch out for: calling f in the Effect case while building. If your map ever calls f before an interpreter supplies an answer, the "does not throw" test catches it. In Scala, flatMap on the enum is a method, so you will this match { ... }.

Exercise 7.3 — sequence, draw, randomSeed, drawScene

JavaScript

export const sequence = (ios) => /* IO<undefined>, left to right */
export const draw = (shape) => /* IO<undefined> */
export const randomSeed = /* IO<number>: a value, not a function */
export const drawScene = (scene) => /* sequence(scene.map(draw)) */

Scala

def sequence(ios: List[IO[Unit]]): IO[Unit] = ???
def draw(shape: Shape): IO[Unit] = ???
lazy val randomSeed: IO[Long] = ???
def drawScene(scene: Scene): IO[Unit] = ???

F#

let sequence (ios: IO<unit> list) : IO<unit> = failwith "TODO m07"
let draw (shape: Shape) : IO<unit> = failwith "TODO m07"
let randomSeed : IO<uint32> = (* a value *)
let drawScene (scene: Scene) : IO<unit> = failwith "TODO m07"

Proved by: randomSeed is answered with the seed given to trace, randomSeed records a RandomSeed op, draw records a Draw op and produces undefined (Scala/F#: ()), drawScene draws the shapes in order, drawScene of an empty scene has no ops, sequence of nothing has no ops, sequence runs effects left to right.

Watch out for: building sequence right-nested (section 12). The m07 tests won't notice a left-nested one, but Module 9's 100 000-draw test will.

Exercise 7.4 — trace, the pure interpreter

JavaScript

export const trace = (io, seed) => /* { result, ops } */

Scala

final case class Traced[A](result: A, ops: List[Op])
def trace[A](io: IO[A], seed: Long): Traced[A] = ???

F#

type Traced<'a> = { Result: 'a; Ops: Op list }
let trace (seed: uint32) (io: IO<'a>) : Traced<'a> = failwith "TODO m07"

Answer every RandomSeed with the seed (Seed(seed) in Scala/F#), every Draw with unit (Done), and collect the ops in the order they were performed. Use recursion, not mutation.

Proved by: trace of pure(3) has result 3 and no ops, and every other test in the module (they all observe programs through trace).

Run (all of Module 7):

  • JS: cd js && node --test "test/m07-*.test.js"
  • Scala: cd scala && ./test.sh M07
  • F#: cd fsharp && ./test.sh M07

Hints

Exercise 7.1 — nudge

pure builds the "finished" case. effect builds the other case: which op? And which function, among those you've already written, takes an answer and returns "a finished program whose result is that answer"?

Exercise 7.1 — near-solution (try the others first)
export const pure = (a) => ({ tag: 'Pure', value: /* ? */ })
export const effect = (op) => ({ tag: 'Effect', op, k: /* a function you just wrote */ })

In Scala, IO.Pure(a) and IO.Effect(op, /* ? */); in F#, Pure a and Effect (op, (* ? *)).

Exercise 7.2 — nudge

Do flatMap first; map is easier afterwards. Split on the two cases of io. In the Pure case you have the value: what do you do with f? In the Effect case you have op and k but no value: what must the new Effect contain? Its op is the same. Its continuation receives an answer r … what can you compute from r with k, and what must happen to that afterwards?

Exercise 7.2 — stronger hint

For Effect(op, k), k(r) is "the rest of the original program". You want "the rest of the original program, and then f". You're writing the function that means "and then f" right now — call it recursively on k(r). The recursion happens inside the new continuation, so it only runs when an interpreter calls it.

For map, use flatMap and pure: "and then finish with f of the value".

Exercise 7.2 — near-solution (try the others first)

JavaScript

export const flatMap = (io, f) =>
  io.tag === 'Pure'
    ? /* ? */
    : { tag: 'Effect', op: io.op, k: (r) => /* ? uses io.k, r, flatMap and f */ }

export const map = (io, f) => flatMap(io, (a) => /* ? */)

Scala

def flatMap[B](f: A => IO[B]): IO[B] = this match
  case Pure(a)      => /* ? */
  case Effect(op, k) => Effect(op, r => /* ? */)

def map[B](f: A => B): IO[B] = flatMap(a => /* ? */)

F#

let rec bind (f: 'a -> IO<'b>) (io: IO<'a>) : IO<'b> =
    match io with
    | Pure a -> (* ? *)
    | Effect (op, k) -> Effect (op, fun r -> (* ? *))

let map (f: 'a -> 'b) (io: IO<'a>) : IO<'b> = io |> bind (fun a -> (* ? *))
Exercise 7.3 — nudge

draw and randomSeed are one effect each (plus, in Scala/F#, a map to turn the OpResult into the right type). sequence is a fold over the list that starts from "the program that does nothing" and glues programs with flatMap, ignoring the unit results. Which fold keeps the tree right-nested (section 12)?

Exercise 7.3 — near-solution (try the others first)

JavaScript

export const sequence = (ios) => ios.reduceRight((rest, io) => flatMap(/* ? */, () => /* ? */), /* ? */)
export const draw = (shape) => effect({ tag: 'Draw', shape })
export const randomSeed = /* ? one effect */

Scala

def sequence(ios: List[IO[Unit]]): IO[Unit] = ios.foldRight(/* ? */)((io, rest) => /* ? */)
lazy val randomSeed: IO[Long] = effect(Op.RandomSeed).map {
  case OpResult.Seed(v) => /* ? */
  case OpResult.Done    => throw new IllegalStateException("RandomSeed must be answered with a Seed")
}

F#

let sequence (ios: IO<unit> list) : IO<unit> = List.foldBack (fun io rest -> (* ? *)) ios (* ? *)
Exercise 7.4 — nudge

trace of a Pure is easy: a result and no ops. For an Effect(op, k), which answer do you give k? Once you have k(answer), you have a smaller program; trace it (recursion!) and combine its result with op.

Exercise 7.4 — near-solution (try the others first)

JavaScript

const answer = (op, seed) => /* seed for RandomSeed, undefined for Draw */

export const trace = (io, seed) => {
  if (io.tag === 'Pure') return { result: io.value, ops: [] }
  const rest = trace(/* ? */, seed)
  return { result: rest.result, ops: [/* ? */, ...rest.ops] }
}

Scala (recursion that is not in tail position is fine here; @tailrec with an accumulator is a nice extra)

def trace[A](io: IO[A], seed: Long): Traced[A] = io match
  case IO.Pure(a)       => Traced(a, Nil)
  case IO.Effect(op, k) =>
    val rest = trace(k(/* ? answer for op */), seed)
    Traced(/* ? */, /* ? */)

F#

let rec trace (seed: uint32) (io: IO<'a>) : Traced<'a> =
    match io with
    | Pure a -> { Result = a; Ops = [] }
    | Effect (op, k) ->
        let rest = trace seed (k ((* ? answer for op *)))
        { rest with Ops = (* ? *) }

Check your understanding

  1. In one sentence each: what is the difference between Math.random() and randomSeed? Between ctx.fillRect(...) and draw(rect)?
  2. Why can't an IO be a plain list of Ops? Give a concrete program from the Snake game that a list could not express.
  3. Predict trace(flatMap(randomSeed, (s) => map(draw(r), () => s * 2)), 21): what are result and ops?
  4. Predict: does const p = map(randomSeed, (s) => { throw new Error('x') }) throw? Does trace(p, 0)? Does flatMap(pure(1), () => { throw new Error('x') })? Explain each answer using the structure of flatMap.
  5. Why do the tests compare trace(...) results instead of comparing IO values directly? What is "observational equality", and why is it the right notion of "the same program" here?
  6. State the associativity law in words. Which everyday refactoring (think "extract function") would be unsafe if it didn't hold?
  7. You build sequence with a left fold and every m07 test passes. What breaks later, and why doesn't the loop in the interpreter save you?
  8. To add sound to the game you'd add {tag:'PlaySound', name}. List every place in the code that has to change. Which place does not have to change at all, and why is that the point of the design?

In production

  • Haskell's IO is the original: main :: IO () is a value that the runtime performs; every effectful function returns an IO description. Haskell's do-notation is the same sugar as Scala's for and F#'s computation expressions (Haskell wiki: IO inside).
  • cats-effect (Scala) gives you IO[A] with pure, map, flatMap, IO.delay/IO.blocking to suspend side-effecting code, and a runtime that interprets it on a fiber scheduler with stack safety, cancellation and concurrency (cats-effect). The book Functional Programming in Scala, chapter 13, builds an IO very much like ours and then makes it stack-safe.
  • ZIO (Scala) uses ZIO[R, E, A]: a description of a program that needs an environment R, may fail with E, or succeeds with A (ZIO). The R is a typed version of our Platform.
  • Free monads are used directly to separate a program's instructions from their interpretation, e.g. to run the same business logic against a real database and an in-memory test double (Gabriel Gonzalez, "You Could Have Invented Free Monads").
  • Elm commands (Cmd msg) are effect descriptions returned by update; the Elm runtime performs them and feeds results back as messages (Elm guide: Commands and Subscriptions). In F#, Elmish has the same Cmd type (Elmish).

Further reading