The IOMonad:
Managing Side Effects with IO
- How to describe side effects without performing them immediately
- Building pure functional programs with deferred execution
- Composing complex side-effecting operations using
mapandflatMap - The difference between describing effects and running them with
unsafeRunSync - Creating testable, composable programs that separate logic from execution
The Problem: Side Effects Everywhere
Consider a method that loads configuration, connects to a database, and logs the result:
// Every call executes immediately — untestable, unrepeatable, order-dependent
Config config = loadConfig(); // reads disk
Connection conn = connectToDb(config); // opens network socket
logger.info("Connected to " + conn.endpoint()); // writes to stdout
return conn;
Each line performs a side effect the instant it runs. You can't test connectToDb without a real database. You can't reorder or retry steps without re-executing earlier effects. And if you want to compose this with other workflows, you're stuck — the effects have already happened.
The IO monad solves this by separating description from execution. An IO<A> value is a recipe for a computation that will produce an A when run — but nothing happens until you explicitly say "go." This means you can build, compose, and inspect entire programs as pure values, then execute them once at the application boundary.
// Describe effects — nothing executes yet
Kind<IOKind.Witness, Config> loadCfg = IO_OP.delay(() -> loadConfig());
Kind<IOKind.Witness, Connection> connect = ioMonad.flatMap(
cfg -> IO_OP.delay(() -> connectToDb(cfg)), loadCfg);
Kind<IOKind.Witness, Connection> logged = ioMonad.peek(
conn -> logger.info("Connected to " + conn.endpoint()), connect);
// Execute the entire recipe at the edge
Connection conn = IO_OP.unsafeRunSync(logged);
The logic is testable (swap loadConfig for a stub), composable (chain more steps with flatMap), and the effects happen exactly once, in exactly the order you specified.
Core Components
The IO functionality is built upon these key pieces:
| Component | Role |
|---|---|
IO<A> | Wraps a Supplier<A> — describes an effect that produces A when run. Directly extends IOKind<A>, so no wrapper allocation is needed. |
IOKind<A> / IOKindHelper | HKT bridge: widen() and narrow() (zero-cost casts), delay() to create deferred effects, unsafeRunSync() to execute them |
IOMonad | Type class instance (Monad<IOKind.Witness>): provides map, flatMap, of, and ap for composing IO programs |
| Type Class Operation | What It Does |
|---|---|
IO_OP.delay(supplier) | Wrap a side effect — nothing executes yet |
ioMonad.of(value) | Lift a pure value into IO (no effect) |
ioMonad.map(f, fa) | Transform the eventual result without adding new effects |
ioMonad.flatMap(f, fa) | Sequence two effects — the second can depend on the first's result |
ioMonad.ap(ff, fa) | Apply a function-in-IO to a value-in-IO |
IO_OP.unsafeRunSync(fa) | Execute — run the recipe and produce the result. Call this at the edge. |
- Error handling — Exceptions thrown during
unsafeRunSyncpropagate directly. For typed error handling, combine with EitherT or wrap operations with Try. - Async execution — IO runs synchronously on the calling thread. For async, see CompletableFutureMonad. For virtual-thread concurrency, see VTaskPath.
- Resource management — IO alone doesn't guarantee cleanup. Use IOPath's bracket pattern for safe resource handling.
Working with IO
The following examples build a small program step by step: creating IO actions, composing them, then executing the result.
Use IO_OP.delay to capture side effects. Use ioMonad.of for pure values within IO.
Monad<IOKind.Witness> ioMonad = Instances.monad(io());
java.util.Scanner scanner = new java.util.Scanner(System.in);
// IO action to print a message — nothing happens yet
Kind<IOKind.Witness, Unit> printHello = IO_OP.delay(() -> {
System.out.println("Hello from IO!");
return Unit.INSTANCE;
});
// IO action to read a line from the console — nothing happens yet
Kind<IOKind.Witness, String> readLine = IO_OP.delay(() -> {
System.out.print("Enter your name: ");
return scanner.nextLine();
});
// Lift a pure value — no side effect at all
Kind<IOKind.Witness, Integer> pureValueIO = ioMonad.of(42);
// Capture a time-dependent side effect
Kind<IOKind.Witness, Long> currentTime = IO_OP.delay(System::currentTimeMillis);
None of these execute when created. The Supplier inside delay is stored, not called.
Use IO_OP.unsafeRunSync to run the computation. This is the "end of the world" — call it at application boundaries, not deep inside your logic.
// Execute printHello — now the effect happens
IO_OP.unsafeRunSync(printHello); // prints "Hello from IO!"
// Execute pureValueIO
Integer value = IO_OP.unsafeRunSync(pureValueIO);
System.out.println("Fetched: " + value); // Output: 42
// Running the same action again re-executes the effect
IO_OP.unsafeRunSync(printHello); // prints "Hello from IO!" again
// Exceptions propagate — handle at the boundary
Kind<IOKind.Witness, String> risky = IO_OP.delay(() -> {
if (Math.random() < 0.5) throw new RuntimeException("Boom!");
return "OK";
});
try {
IO_OP.unsafeRunSync(risky);
} catch (RuntimeException e) {
System.err.println("Caught: " + e.getMessage());
}
map transforms the result of an IO action without executing it. flatMap sequences two IO actions — the second can depend on the first's result.
Monad<IOKind.Witness> ioMonad = Instances.monad(io());
// --- map: transform a result ---
Kind<IOKind.Witness, String> readLineAction = IO_OP.delay(() -> "Test Input");
Kind<IOKind.Witness, String> greetAction = ioMonad.map(
name -> "Hello, " + name + "!",
readLineAction
);
System.out.println("Greet action created, not executed yet.");
String greeting = IO_OP.unsafeRunSync(greetAction);
System.out.println(greeting); // Output: Hello, Test Input!
// --- flatMap: sequence dependent effects ---
Kind<IOKind.Witness, String> getName = IO_OP.delay(() -> {
System.out.println("Effect: Getting name...");
return "Alice";
});
Function<String, Kind<IOKind.Witness, Unit>> printGreeting = name ->
IO_OP.delay(() -> {
System.out.println("Welcome, " + name + "!");
return Unit.INSTANCE;
});
// Combine — nothing runs until unsafeRunSync
Kind<IOKind.Witness, Unit> combinedAction = ioMonad.flatMap(printGreeting, getName);
IO_OP.unsafeRunSync(combinedAction);
// Output:
// Effect: Getting name...
// Welcome, Alice!
Beyond map and flatMap, IOMonad provides utility methods for common patterns.
Kind<IOKind.Witness, String> getAliceName = ioMonad.of("Alice");
Function<String, Kind<IOKind.Witness, Unit>> printGreeting = name ->
IO_OP.delay(() -> { System.out.println("Welcome, " + name + "!"); return Unit.INSTANCE; });
Function<String, Kind<IOKind.Witness, Unit>> doNothing = name -> ioMonad.of(Unit.INSTANCE);
// peek — log without affecting the pipeline
Kind<IOKind.Witness, String> logged =
ioMonad.peek(name -> System.out.println("LOG: Name -> " + name), getAliceName);
// flatMapIfOrElse — conditional branching
Kind<IOKind.Witness, Unit> conditionalGreeting =
ioMonad.flatMapIfOrElse(
name -> !name.equalsIgnoreCase("admin"), // predicate
printGreeting, // true branch
doNothing, // false branch
logged // input value
);
// as — replace the result, keeping the effect
Kind<IOKind.Witness, Unit> finalProgram =
ioMonad.as(
Unit.INSTANCE,
ioMonad.peek(_ -> System.out.println("Program finished."), conditionalGreeting));
IO_OP.unsafeRunSync(finalProgram);
// Output:
// LOG: Name -> Alice
// Welcome, Alice!
// Program finished.
Back to the One-Liner
IO is the layer we wrap around the Foundations one-liner when we want side effects to be described rather than performed until interpretation:
IO.delay(() -> repo.find(id))
.map(maybe -> maybe.toEitherPath()
.focus().attributes().at(key)
.modify(spec::validateAndCoerce))
.flatMap(eitherPath ->
IO.delay(() -> eitherPath.flatMap(repo::save)));
Every effectful step is now wrapped in IO.delay, which means the whole expression is a value: a description of work to do, not the work itself. Tests can assemble this value, swap repo for a stub, and never run a real save. Production calls unsafeRunSync (or composes the value into a larger interpreter) once, at the edge.
See One Line, Six Layers for the wider picture and Free Monad when we want the description to be a structured program that several interpreters can run in different ways.
When to Use IO
| Scenario | Use |
|---|---|
| Deferring side effects for testability and composition | IO / IOMonad |
| Side effects with typed error handling | Combine with EitherT |
| Application-level effect composition | Prefer IOPath for fluent API |
| Concurrent / async execution | Consider VTaskPath or CompletableFutureMonad |
IO<A>is a description, not an execution. Nothing happens untilunsafeRunSyncis called.IO<A>directly extendsIOKind<A>, sowiden/narroware zero-cost casts — no wrapper allocation.maptransforms the eventual result;flatMapsequences dependent effects. Neither triggers execution.- Call
unsafeRunSyncat the application boundary ("end of the world") — never deep inside business logic. - Re-running the same IO value re-executes the effect. IO values are recipes, not cached results.
For most use cases, prefer IOPath which wraps IO and provides:
- Fluent composition with
map,via,recover - Seamless integration with the Focus DSL for structural navigation
- A consistent API shared across all effect types
// Instead of manual IO chaining:
Kind<IOKind.Witness, Config> config = IO_OP.delay(() -> loadConfig());
Kind<IOKind.Witness, String> value = ioMonad.flatMap(
c -> IO_OP.delay(() -> c.getValue("key")), config);
// Use IOPath for cleaner composition:
IOPath<String> value = Path.io(() -> loadConfig())
.via(c -> Path.io(() -> c.getValue("key")));
See Effect Path Overview for the complete guide.
IO has dedicated JMH benchmarks measuring lazy construction, platform thread execution, and map/flatMap chains. Key expectations:
- Construction (delay, pure) is very fast (~100+ ops/us) — IO is a lazy wrapper with no immediate execution
- IO vs VTask: IO is ~10-30% faster for simple operations due to no virtual thread spawn overhead
- Deep chains (50+) complete without error — composition overhead dominates at depth
- At high concurrency (1000+ tasks), VTask scales better than IO due to virtual threads
./gradlew :hkj-benchmarks:jmh --includes=".*IOBenchmark.*"
./gradlew :hkj-benchmarks:jmh --includes=".*VTaskVsIOBenchmark.*"
See Benchmarks & Performance for full details, comparison benchmarks against VTask, and how to interpret results.