What Haskell Teaches: purity, laziness, and the cost of side effects
How Haskell's pure, lazy core reframes side effects as data, and what OCaml, Rust, and Guji each keep, drop, or trade away.
Most languages treat a side effect as free. Printing a line, mutating a counter, reading a file: each is just a statement you write, indistinguishable from arithmetic. Haskell's central claim is that this freedom is expensive, and that making effects visible buys you something worth more than the convenience it costs. Looking at Haskell next to OCaml, Rust, and Guji shows what that bet is, and which parts of it the other three keep.
Purity as a type-level promise
In Haskell a function's type is a contract about what it can do, not just what it returns. A value of type Int -> Int cannot print, cannot read a clock, cannot launch a missile. It is, in the literal sense, a function: same input, same output, every time. Effects do not vanish; they get a type. An action that performs IO and yields an Int has type IO Int, and that IO tag is contagious - it propagates through any signature that touches it.
double :: Int -> Int -- pure, cannot do anything but compute
double x = x + x
greet :: String -> IO () -- the IO says: this one talks to the world
greet name = putStrLn ("hello, " ++ name)
The payoff is that the compiler, and the reader, can trust the first signature absolutely. Equational reasoning - replacing a call with its result - is always valid for pure code, which is what makes aggressive rewriting, common-subexpression elimination, and refactoring safe by construction. The cost is that you must thread effects explicitly. Monads, and IO in particular, are the plumbing that lets you sequence effectful steps while keeping the purity boundary honest:
main :: IO ()
main = do
putStr "name? "
name <- getLine
greet name
The do block is sugar over >>=, and the monad is not a special case the compiler blesses; it is an ordinary type class. Type classes are Haskell's second big idea: ad-hoc polymorphism resolved at compile time, so Monad, Functor, Eq, and Num are all just interfaces a type can satisfy, with the right instance selected by inference.
Laziness, and why it is load-bearing
Haskell is non-strict by default: an expression is not evaluated until its value is demanded. This is more than an optimization. It lets you write take 5 (map expensive [1..]) over an infinite list and pay only for what you consume, and it lets control flow emerge from data - if-like behavior, short-circuiting, and producer/consumer pipelines all fall out of the evaluation order rather than being built in.
nats :: [Integer]
nats = [0..] -- infinite, harmless until forced
firstFive :: [Integer]
firstFive = take 5 (map (^2) nats) -- [0,1,4,9,16]
Laziness is also where the cost surfaces most sharply. Unforced thunks accumulate, and a fold that looks tail-recursive can build a tower of deferred additions that blows the stack - the notorious space leak. Real Haskell answers this with strictness annotations (!), seq, and strict folds, so in practice you reason about evaluation order more than the "don't worry about it" pitch suggests. Purity and laziness reinforce each other here: because evaluation has no observable side effects, the runtime is free to defer, share, and reorder it. Laziness is only cheap to reason about because the language is pure.
OCaml: most of the discipline, none of the suspense
OCaml shares Haskell's lineage - algebraic data types, pattern matching, a strong Hindley-Milner type system, type inference that rarely needs help - but makes two opposite choices. It is strict, and it is not pure: mutation, printf, and exceptions are available anywhere, untracked by the type.
let rec sum = function
| [] -> 0
| x :: xs -> x + sum xs
The result is a language that feels close to Haskell to write but far simpler to predict. There are no thunks to reason about and no IO wrapper to thread, so performance is legible and FFI is easy. What you give up is the guarantee: an OCaml signature int -> int probably is pure, but nothing enforces it. OCaml keeps the data modeling and the inference, and quietly drops the part of Haskell that polices effects. For a great many programs that trade is exactly right, which is why OCaml reads as the pragmatic cousin.
Rust: effects you can see, ownership you must satisfy
Rust comes at the same problem from systems programming rather than from the lambda calculus, and lands somewhere genuinely new. It is strict and unapologetically allows mutation - but mutation is governed by ownership and borrowing rather than left implicit. A &mut T in a signature is a side effect you can see in the type, and the borrow checker proves at compile time that no two parts of the program alias it dangerously.
fn double(x: i32) -> i32 { x + x } // pure by construction
fn push_sq(v: &mut Vec<i32>, x: i32) { // the &mut is the visible effect
v.push(x * x);
}
Where Haskell isolates effects by routing them through a type (IO), Rust permits effects but constrains aliasing so they cannot race or corrupt. The shared ancestry shows through anyway: Option and Result instead of null and exceptions, match exhaustiveness, iterator chains that read like lazy lists (and are lazy, per-iterator, until a consuming method runs). Traits play the role type classes do. Rust takes Haskell's "make the dangerous thing visible" and spends it on memory safety without a garbage collector, which is a different budget than purity but the same instinct.
Guji: immutable by default, effects kept narrow
Guji, an in-house functional-first language whose v0 is a tree-walking interpreter, sits closer to the ML side of the family while borrowing the sigils and text-first sensibility of the Perl lineage. Bindings are immutable by default and mutation is opt-in with mut; reassigning an immutable binding is rejected rather than silently allowed.
sub main(): Int {
$name = "world"
print("hello, $name")
@nums = [1, 2, 3, 4, 5, 6]
$evens = @nums.filter({ $_ % 2 == 0 }).map({ $_ * $_ }).sum()
print("sum of squares of evens: $evens")
0
}
Like the others Guji models data with algebraic types and forces you to handle every case with an exhaustive match, so a recursive structure and its fold read much as they would in Haskell or OCaml:
enum Tree[T] {
Leaf($value: T)
Node($left: Tree[T], $right: Tree[T])
sub sum($self): Int {
match $self {
Leaf($v) { $v }
Node($l, $r) { $l.sum() + $r.sum() }
}
}
}
Guji is strict, not lazy - evaluation order is the obvious one, and lazy or infinite sequences are explicitly deferred rather than the default. It declines Haskell's purity boundary too: there is no IO type, and print is just a prelude function. What it keeps from the same tradition is the default. Values do not change out from under you, "modifying" an object returns a new one, and the signature feature is first-class text processing: regexes are a type, matched with the ~~ operator, which yields an Option you must unpack.
$line = 'ada@example.com'
match $line ~~ /(?<user>\w+)@(?<host>\w+)/ {
Some($m) { print("user { $m<user>.unwrap() } at { $m<host>.unwrap() }") }
None { print("no match") }
}
Here Guji's effect story is one of convention plus immutability rather than a type-level wall: errors are Result and Option values rather than exceptions, the ? operator propagates them, and there is no ambient mutable state to leak between tasks. It is the lightweight version of the lesson - get the defaults right and most accidental effects never arise - without paying for a monad transformer stack to get there.
The cost, and what it buys
The four languages line up as answers to one question: how visible should an effect be? Haskell makes it maximally visible and pays in laziness-induced reasoning and IO plumbing, buying total equational trust in pure code. OCaml keeps the modeling and drops the policing, buying simplicity. Rust makes mutation visible and aliasing provable, buying memory safety without a collector. Guji makes immutability the default and effects narrow by convention, buying most of the safety for a fraction of the ceremony. Larry Wall's line that easy things should be easy and hard things possible cuts both ways here: Haskell makes the honest thing easy and the careless thing slightly harder, and the other three each pick a different point on that curve. The enduring lesson is not "be pure." It is that an effect you cannot see is an effect you cannot reason about, and every one of these languages, in its own dialect, decided that was a price worth charging up front.