IO in Depth

Course 2 · Ch 4
IO in Depth
Chapter 1's own throughline, paid off in full

haskell1-1 opened this entire course with a claim: main :: IO () tells you, in the type itself, that side effects are possible. Sixteen chapters later, with haskell2-3's own monad reveal now in hand, here's the full mechanism behind that claim — and it's stronger than it first looked.

IO as a Monad — Recap

haskell2-3 already named IO's own >>= as sequencing side-effecting actions one after another. This chapter goes deeper into why that specific design choice matters as much as it does.

"Infection" — Why Calling IO Code Makes You IO Too

impureFunction :: Int -> Int impureFunction x = x + getLine -- DOES NOT TYPE-CHECK, ever

Here's the central mechanic: getLine has type IO String, not String — it genuinely cannot be added to an Int, no matter what. The only way to actually get the String out of an IO String is >>= or do-notation — and both of those require the surrounding function to itself return something wrapped in IO. Calling IO code doesn't just fail quietly; it forces the caller's own type signature to admit it.

Purity Is Contagious the Other Way Too — main Can't Escape Its Own Type Either

Even main itself gets no free pass — its signature is IO (), declared honestly like everything else. There is genuinely no "trusted root" that silently does IO without its type saying so. Contrast this directly against every other language covered on this site: Java's main, C#'s Main, Python's top-level code all execute freely, with zero tracking of what they actually touch.

The Real, Concrete Consequence — You Can See Purity by Reading a Signature

parseConfig :: String -> Config -- GUARANTEED pure — no file read, no network call, no console output, nothing hidden

This is the payoff haskell1-1's own comparison table promised, now fully justified: parseConfig's signature is a compiler-checked guarantee, not a hope or a convention. If it needed to read a file or touch the network, its own type would be forced to say IO Config instead — there's no way to sneak IO past the signature.

unsafePerformIO — The Honest Escape Hatch That Breaks the Promise

unsafePerformIO :: IO a -> a -- genuinely exists — a real way to lie to the type system

An honest acknowledgment, matching this course's own established pattern of naming real limitations rather than pretending everything is unbreakable: unsafePerformIO genuinely exists, letting a programmer forcibly extract a value from IO and misrepresent a function as pure when it isn't. Almost never appropriate in real code — it exists mainly for narrow, expert-level interop cases, not everyday use.

Practical IO — A Pure Core, IO Shell Shape

main :: IO () main = do input <- getLine let result = pureBusinessLogic input -- zero IO in this function's own type putStrLn result

A genuinely practical, real-world pattern: keep as much logic as possible in ordinary pure functions, and push all actual IO to a thin outer layer that calls into that pure core. pureBusinessLogic is fully testable with no IO mocking at all — a real, concrete engineering benefit this whole design buys.

AspectJava / C# / PythonHaskell
Compiler tracks side effectsno, neveryes — via IO in the type
A "trusted" entry point exempt from trackingyes — main runs freelyno — main :: IO () is honest too
Can a signature alone prove puritynoyes — a compiler-checked guarantee
Structure real programs with a pure core and a thin IO shell
Push actual IO to the outermost layer and keep genuine business logic pure — the pure core becomes trivially testable with ordinary function calls, no mocking framework required at all.
unsafePerformIO is a real, not purely theoretical, way to break the guarantee
A function whose type signature promises purity but internally uses unsafePerformIO to sneak around it genuinely can lie to every caller relying on that signature — a real, if rare, way this chapter's own central guarantee can be violated, worth knowing exists rather than assuming the boundary is literally unbreakable.

Coding Challenges

Challenge 1

Attempt to write a function that adds the result of getLine directly to an Int without any do-notation or >>=, show the resulting compile error, and explain in a comment exactly why it fails.

📄 View solution
Challenge 2

Write a small program with a pure function calculateTotal :: [Int] -> Int and a main that reads a line, parses it into a list of numbers, calls calculateTotal, and prints the result — keeping calculateTotal itself completely free of IO in its type.

📄 View solution
Challenge 3

Write a short comment contrasting Haskell's main :: IO () against Java's public static void main(String[] args), specifically addressing whether either language's own main gets a "free pass" from its own safety mechanism.

📄 View solution

Chapter 4 Quick Reference

  • Calling IO code forces the caller's own type to admit IO too — there's no way to quietly extract a value from IO without >>= or do-notation, both of which require IO in the surrounding signature
  • Even main :: IO () is honest about itself — no trusted root gets a free pass, unlike every other language on this site
  • A pure signature (String -> Config) is a real, compiler-checked guarantee of no hidden side effects — the payoff haskell1-1 promised
  • unsafePerformIO genuinely exists as a real, if rarely appropriate, way to break the purity guarantee
  • Pure core, IO shell: push IO to a thin outer layer, keep real logic pure and trivially testable
  • Next chapter: typeclasses in depth — writing custom instances, compared against rust2-2's traits and Kotlin/Java's interfaces