How to Manage Side Effects with IO¶
This guide shows you how to use IO to defer and sequence side-effectful operations — keeping your pure computation separate from I/O until you are ready to execute it.
Prerequisites¶
katharosinstalledFamiliarity with
fmapand the|bind operator
The core model¶
IO[A] wraps a pure value together with a deferred side-effect function. The side effect does not run until you call .execute().
fmaptransforms the wrapped value and produces a newIOwith no side effect.|(bind) threads the value into the next function and merges both side effects, so they execute in order when.execute()is called.>>(then) sequences twoIOvalues, discarding the first value but merging both side effects in the same way as|.
Creating an IO value with a side effect¶
Use FunctionWithSideEffect to attach a callable (a function that takes no arguments and returns None) to an IO value:
from katharos.types.side_effect import IO, FunctionWithSideEffect
message = "Processing complete"
io = IO(
value=message,
io_func=FunctionWithSideEffect(f=lambda: print(message)),
)
# Nothing has printed yet.
io.execute() # prints: Processing complete
Transforming the value without triggering the side effect¶
fmap maps a function over the wrapped value and produces a new IO. The side effect of the original is not carried over:
io_upper = io.fmap(str.upper)
# io_upper.value == 'PROCESSING COMPLETE'
# io_upper has no side effect (FunctionWithSideEffect.no_op())
To transform the value and keep the side effect, create a new IO explicitly:
upper_value = message.upper()
io_upper_with_effect = IO(
value=upper_value,
io_func=FunctionWithSideEffect(f=lambda: print(upper_value)),
)
Sequencing multiple IO actions with >>¶
>> (the then operator) sequences two IO values: it keeps the second value but merges both side effects so they execute in order:
log_start = IO(
value=None,
io_func=FunctionWithSideEffect(f=lambda: print("Starting...")),
)
log_end = IO(
value=42,
io_func=FunctionWithSideEffect(f=lambda: print("Done. Result:", 42)),
)
pipeline = log_start >> log_end
# pipeline.value == 42
pipeline.execute()
# prints:
# Starting...
# Done. Result: 42
Building a deferred computation pipeline¶
Use | (bind) to thread the value from one IO into a function that produces the next IO. The side effects accumulate but are not run:
def compute(x: int) -> IO[int]:
result = x * 2
return IO(
value=result,
io_func=FunctionWithSideEffect(f=lambda: print(f"computed: {result}")),
)
def format_result(x: int) -> IO[str]:
text = f"answer={x}"
return IO(
value=text,
io_func=FunctionWithSideEffect(f=lambda: print(f"formatted: {text}")),
)
initial = IO(value=5, io_func=FunctionWithSideEffect.no_op())
pipeline = (
initial
| compute # IO(10, prints "computed: 10")
| format_result # IO("answer=10", prints "formatted: answer=10")
)
# Nothing has run yet.
print(pipeline.value) # answer=10
pipeline.execute()
# prints:
# computed: 10
# formatted: answer=10
Mixing fmap and | in the same pipeline¶
Use fmap for steps that transform the value but produce no side effect. Use | for steps that both transform the value and produce a side effect. Both can appear in the same chain:
def double(x: int) -> int:
return x * 2
pipeline = (
IO.pure(5)
.fmap(double) # IO(10) — pure transform, no side effect
.fmap(double) # IO(20) — pure transform, no side effect
| (lambda x: IO( # side-effectful step
value=x + 1,
io_func=FunctionWithSideEffect(f=lambda: print(f"after doubles: {x}")),
))
)
print(pipeline.value) # 21
pipeline.execute() # prints: after doubles: 20
Checking the value without executing¶
Read .value to inspect the wrapped result at any time without triggering the side effect:
pipeline = IO.pure(10).fmap(lambda x: x + 5)
assert pipeline.value == 15 # no side effect runs