A pure function can't print or read — so Haskell turns an effect into a value: an IO recipe that does nothing until the runtime runs it. It's the same idea as Scala's cats-effect IO, just built into the language.
Everything so far has been pure: same inputs, same output, no surprises. But real programs print, read files, hit the network. This chapter is about how Haskell does all that without giving up purity — by making an effect into a value you can pass around, and only running it at the very edge.
The whole arc, one idea at a time:
IO a is a recipe — a description of an effect, not the act of doing it.main — the one recipe the runtime actually runs.do, core/shell, the toolkit — how you sequence effects and keep them at the edge.Here's the tension. A pure function is defined by one promise: same inputs, same output, nothing else observable. Call it a thousand times — no files touched, no console written, no clock read. That's what makes Haskell code so easy to reason about.
But println obviously breaks that promise. Printing is an observable side effect. So a genuinely pure function simply cannot print — there's no honest type you could give it.
And yet Haskell programs clearly do things: they greet you, read input, write logs. So how? The trick isn't an escape hatch from purity. It's a change of plan: instead of performing an effect, a function returns a description of one. The description is a value, pure as any other. Something else — the runtime — does the actual performing later.
() => println("hi") instead of println("hi"). The first is a value — nothing prints until someone calls it. Haskell's IO is that same deferral, made first-class and type-tracked.IO a is a recipe, not the actRead IO a as: "a description of an effect that, when run, produces an a." It's a recipe card. Holding the card doesn't cook anything — building the value runs nothing.
So putStrLn "hi" has type IO (): a recipe that, when run, prints and yields the empty value (). And getLine has type IO String: a recipe that, when run, reads a line and yields a String.
putStrLn "hi" :: IO () getLine :: IO String
The Scala anchor is exact: a cats.effect.IO[A] is the same deal — a value describing an effect that you haven't run yet. Or, more humbly, a () => A you're holding but haven't called. Constructing it is pure; only running it has effects. The widget below makes the gap between defining and running impossible to miss.
main :: IO ()If building recipes runs nothing, who runs them? Exactly one recipe: main. It has type IO (), and it's the single action the runtime actually executes when your program starts.
main :: IO ()
That's the whole rule. Anything effectful in your program does something only because it's reachable from main — stitched into the one recipe the runtime runs. An IO value you build but never wire into main is just an unused recipe card sitting in a drawer; it never fires.
It maps cleanly onto Scala. IOApp.Simple's run: IO[Unit] is the exact same contract: hand the framework one IO, and it runs that — and only that — at the edge of the world. Your main is Haskell's run.
doOne recipe isn't much. You want to print a prompt, then read a line, then greet — in order. That ordering is what do gives you. It's the very same do you met in Chapter 6, because IO is a monad:
do
putStrLn "name?"
n <- getLine
putStrLn ("hi " ++ n)
Two kinds of line, two behaviors:
putStrLn "name?" just sequences the effect — run it, ignore whatever it yields, move on.n <- getLine runs the recipe and binds its result — here, the line the user typed gets the name n, usable on later lines.Under the hood do is plain >>= from Chapter 6 — but you rarely write it that way. Step through it below: each line runs in turn, each <- fills in its bound value, and the console grows line by line.
Now the design rule that makes IO pleasant instead of viral. Keep your real computation — parsing, calculating, formatting — in pure functions. Push the effects — reading input, writing files, printing — out to a thin IO shell that calls into that pure core.
This is the functional-core / imperative-shell pattern, and the same advice holds in Scala: don't sprinkle IO through your domain logic. A pure core is trivially testable (no mocks, no fixtures — just inputs and outputs) and easy to reason about; the shell stays small enough to eyeball.
The litmus test: could you test this function with nothing but a plain assertion? If yes, it belongs in the core. If it needs the console or the filesystem, it belongs in the shell. Sort the operations below and see how you did.
parse, add, and format never need an IO in their type. main reads input, hands raw strings to the pure core, and prints what comes back. Effects live at exactly two points — in and out.You'll reach for the same handful of IO actions constantly. Here's the working set, one line each:
putStrLn :: String -> IO () — print a string followed by a newline.print :: Show a => a -> IO () — show a value, then print it (great for numbers, lists, anything Showable).getLine :: IO String — read one line of input as a String.readFile :: FilePath -> IO String — read a whole file's contents into a String.readLn :: Read a => IO a — read a line and parse it into a typed value (e.g. an Int).return :: a -> IO a — wrap a plain value as a no-op recipe that just yields it.That last one trips up Scala folks: return here is not a control-flow keyword. It doesn't jump anywhere — it just lifts a pure value into IO, exactly like IO.pure in cats-effect. It's the pure from Chapter 5, wearing the IO hat.
return is not return. It never short-circuits a do block or exits a function. return 5 is simply the value IO 5; the lines after it still run. If you're picturing Java/Scala's early-exit return, drop that picture entirely.Step back and the apparent paradox dissolves. IO is just a monad — the >>= machinery from Chapter 6, applied to a type whose values describe effects. do notation threads those recipes together into one bigger recipe. No magic, no exceptions to the rules.
So referential transparency still holds. When you write let r = putStrLn "hi", then use r twice, you've described the print twice — you haven't performed it twice mid-expression. You're passing recipes around as ordinary values; nothing fires until that combined recipe is reached from main and the runtime runs it.
That's the deep win. Effects became data. And data obeys all the laws you already trust — substitution, equational reasoning, fearless refactoring — right up to the single, explicit edge where the runtime takes over.
IO value; the runtime is the impure thing that runs it. Purity all the way down, effects at the boundary.Side by side: cats-effect IO with a for-comprehension on the left, Haskell's built-in IO with do on the right. Same recipe, same ordering, same "run at the edge" contract.
You can now describe effects as values, sequence them with do, and keep them quarantined at the edge. Next we go under the hood of Haskell's other defining trait — laziness — and the thunks, evaluation order, and space leaks that come with it.