Purity and Side Effects
A pure function's entire relationship with the world is its inputs and its return value — everything else lives in IO, a type that turns 'having an effect' into 'being a value.'
What “pure” actually means
A function is pure if it satisfies two conditions:
- Given the same inputs, it always returns the same output (this is referential transparency again, from Chapter 5).
- It has no side effects — it doesn’t print anything, read a file, mutate a global, make a network call, or do anything else observable other than compute and return its result.
-- pure: entirely determined by its argument, does nothing else
priceWithTax :: Double -> Double
priceWithTax price = price * 1.08
Nothing about calling priceWithTax 10.0 can ever surprise you. It can’t fail unpredictably, can’t depend on the time of day, can’t leave anything different in the world afterward. This is an enormously strong guarantee, and Haskell’s type system is built to make it the default — the vast majority of functions you write are ordinary, pure, a -> b functions exactly like this one.
So how does anything actually happen?
Every useful program eventually needs to print something, read a file, or talk to a network — genuine side effects. Haskell doesn’t pretend this isn’t necessary; instead, it makes effects visible in the type:
greet :: IO ()
greet = putStrLn "Hello, pilgrim."
readConfig :: FilePath -> IO String
readConfig path = readFile path
IO () doesn’t mean “a function returning nothing.” It means: “this is a description of an action that, when performed, interacts with the outside world and produces a () (a value carrying no information).” IO String means “an action that, when performed, produces a String.” The IO in the type is not decoration — it’s the whole point. It’s the compiler’s way of guaranteeing that no function without IO somewhere in its type can possibly touch the outside world.
Figure: Pure functions like price, validate, and sortBy live in an inner core with no way out to the world. Effectful actions — readFile, putStrLn, getLine, httpGet — are all tagged IO and live in an outer shell; the two only ever meet at a boundary the type system enforces.
Effects are values, not instructions
Here is the genuinely strange (and genuinely beautiful) part: an IO String isn’t executing anything just by existing. It’s an inert, ordinary Haskell value — a description of an action — sitting there like any other value, until the Haskell runtime actually performs it.
action :: IO ()
action = putStrLn "This line never prints on its own."
main :: IO ()
main = action -- only NOW, when `main` is run by the runtime, does it print
action can be passed around, stored in a list, compared for… well, not equality (functions and IO actions aren’t comparable), but you get the idea: it behaves like data describing “a thing to do,” not like a command that fires the moment you write it down. This is what lets Haskell keep purity everywhere else — IO isn’t an escape hatch that breaks the rules, it’s a type that plays by them, whose values just happen to mean “go do this in the world.”
This design is often summarized as: “Haskell is not a language without side effects — it’s a language with exactly one side effect, sequencing IO actions inside main, and the type system guarantees nothing else can sneak one in.” Everything from file I/O to mutable arrays to random number generation is, one way or another, funneled through IO (or a closely related type) rather than being freely available everywhere.
Chaining effects
Since an IO action is just a value, you need a way to say “do this, then do that with the result” — which is exactly the job of the >>= operator (pronounced “bind”) that Chapter 11 covers in full generality:
main :: IO ()
main = do
putStrLn "What's your name?"
name <- getLine
putStrLn ("Hello, " ++ name ++ "!")
The do block above is convenient syntax for a chain of binds — getLine >>= \name -> putStrLn (...). Notice that name isn’t a value you could obtain any other way: the only way to get the String out of an IO String is to bind it inside another IO action, which keeps that string permanently tagged as “obtained via a side effect,” unable to silently leak into a pure function’s logic.
A common early instinct is to look for a function like unsafeRunIO :: IO a -> a to “just get the value out.” Such a function does technically exist (unsafePerformIO), and its name is a deliberate, loud warning: using it forfeits the referential-transparency guarantee this whole chapter is about, and it’s reserved for extremely rare, carefully justified low-level cases — not ordinary code. If you find yourself reaching for it, that’s usually a sign the function you’re writing should itself just return IO something.
Why this is worth the ceremony
The payoff mirrors Chapter 5’s: a function’s type tells you everything about what it can do. A signature like sortBy :: (a -> a -> Ordering) -> [a] -> [a] isn’t just documentation — it’s a guarantee, checked by the compiler, that this function cannot print to your terminal, cannot read your filesystem, cannot make a network request. In languages without this separation, any function call could, in principle, do anything — you have to read the implementation (and everything it calls) to know it doesn’t. In Haskell, the type signature alone tells you.
IO is, itself, a Monad (Chapter 11) — the same abstraction that models Maybe’s “might not have a value” and lists’ “might have many values” also models “performs effects in the real world.” This is one of the more startling unifications in this whole book: optional values, non-determinism, and interacting with the outside world are, structurally, the same kind of thing, differing only in which specific “extra context” is being carried alongside an ordinary value.