IO Monad
Manage side effects with pure, deferred computations
The IO monad represents computations with side effects. It encapsulates impure operations, allowing you to write pure code that describes effects without executing them immediately. This makes side effects explicit and composable.
IO[T] wraps a computation that produces a value of type T when executed. The computation is deferred until you call unsafe_run().
Creating Computations
Wrapping Side Effects
The IO constructor wraps a computation that will execute when run.
from better_py import IO
# Wrap a computation
io_value = IO(lambda: 42)
# Execute it
io_value.unsafe_run() # 42Lifting Pure Values
The IO.pure method lifts a pure value into an IO context.
from better_py import IO
IO.pure(42).unsafe_run() # 42Creating Delayed Computations
The IO.delay method creates a delayed computation that executes when run.
from better_py import IO
# Delay a value
IO.delay(42).unsafe_run() # 42
# Delay a function
IO.delay(lambda: expensive_computation()).unsafe_run()Use delay to defer expensive operations until they're actually needed.
Executing Computations
Running Side Effects
The unsafe_run method executes the wrapped computation. It's marked "unsafe" because it breaks purity by executing side effects.
from better_py import IO
# Simple computation
IO(lambda: 42).unsafe_run() # 42
# Side effect
IO(lambda: print("Hello")).unsafe_run() # Prints "Hello"The "unsafe" prefix reminds you that this method should be called at the edges of your program (main function, request handlers), not in pure business logic.
Transforming Computations
Mapping Results
The map method transforms the result of an IO computation.
from better_py import IO
IO(lambda: 5).map(lambda x: x * 2).unsafe_run() # 10The transformation is only applied when the computation is run, not when it's created.
Chaining Operations
The flat_map method chains IO computations sequentially.
from better_py import IO
def read_file():
return IO(lambda: open("file.txt").read())
def parse_content(content: str):
return IO(lambda: parse_json(content))
# Chain operations
result = (read_file()
.flat_map(parse_content)
.unsafe_run())Use flat_map when the second computation depends on the result of the first.
Sequencing Operations
The and_then method runs two IO computations in order, discarding the first result.
from better_py import IO
# Run two IOs, discard first result
(IO(lambda: print("First"))
.and_then(IO(lambda: print("Second")))
.unsafe_run())
# Prints:
# First
# SecondUse and_then when you need to run side effects in sequence but don't need the first result.
Filtering Results
The filter method conditionally returns the result or None based on a predicate.
from better_py import IO
IO(lambda: 5).filter(lambda x: x > 3).unsafe_run() # 5
IO(lambda: 2).filter(lambda x: x > 3).unsafe_run() # NoneError Handling
Recovering from Exceptions
The recover method catches exceptions and provides a fallback value.
from better_py import IO
# Catch and handle exceptions
IO(lambda: 1 / 0).recover(lambda e: 0).unsafe_run() # 0
# No effect on success
IO(lambda: 42).recover(lambda e: 0).unsafe_run() # 42Use recover to handle unexpected failures and provide safe defaults.
Retrying on Failure
The retry method re-runs the computation up to a specified number of times if it fails.
from better_py import IO
# Retry up to 3 times (total attempts = times + 1)
attempts = 0
def flaky_operation():
global attempts
attempts += 1
if attempts < 3:
raise Exception("Not ready")
return "Success"
IO(flaky_operation).retry(3).unsafe_run() # "Success" after retriesUse retry for operations that might fail transiently, like network requests.
Real-World Pattern: File Operations with Retry
from better_py import IO
import requests
def fetch_url(url: str) -> IO[str]:
def request():
response = requests.get(url, timeout=5)
response.raise_for_status()
return response.text
return IO(request).retry(3)
def process_url(url: str) -> IO[dict]:
return (fetch_url(url)
.map(lambda text: parse_json(text))
.recover(lambda e: {}))
fetch_and_process = process_url("https://api.example.com").unsafe_run()This pattern shows IO's power: network operations can fail, but retry adds resilience while recover provides safe defaults.
When to Use
Use IO when:
- You need to delay side effects
- You want pure code with explicit effects
- You need retry/recovery logic
- You're interfacing with impure code
- You want to make side effects explicit in types
Don't use IO when:
- You're writing simple scripts (direct execution is fine)
- Side effects are minimal
- You don't need to defer execution
Safety Notes
The unsafe_run() method is marked "unsafe" because:
- It executes side effects
- It can raise exceptions
- It breaks purity
Always call unsafe_run() at the "edges" of your program (main function, request handlers, etc.), not in pure business logic.