03.01 — Effect, Thunk[T], Result[T]#

Three lines in custom_types/ + one in railway/. The whole DSL rides on them.

Triplet#

# src/ocarina/custom_types/effect.py
type Effect = Callable[[], None]
type Effects = tuple[Effect, ...]

# src/ocarina/custom_types/thunk.py
type Thunk[T] = Callable[[], T]

# src/ocarina/railway/result.py
type Result[T] = Ok[T] | Fail
TypeFP semanticsDefinition
EffectSide effect (deferred)() -> None
Thunk[T]Deferred computation with value() -> T (laziness + value)
Result[T]Computation that can failDiscriminated union Ok[T] | Fail

Effect vs Thunk distinction#

log_msg: Effect = lambda: print("hello")        # () -> None
get_42: Thunk[int] = lambda: 42                 # () -> int
fetch:  Thunk[Result[int]] = lambda: Ok(42)     # () -> Result[int]

Function returns something consumed elsewhere → Thunk[T].
Otherwise → Effect.

Effect#

UseLocation
Scenario.setup / Scenario.teardowncustom_types/scenario.py
dispose in BuiltWebDriver[Driver] = tuple[Driver, Effect]custom_types/built_web_driver.py
act_counter_effect, on_run_effect in create_actdsl/testing_with_railway/constructors/create_act.py
CLI effects (effects_factory in CliBuilder)opinionated/cli/builder.py
SuccHandler (success handler of act)dsl/testing_with_railway/internals/action_chain.py (SuccHandler = Effect)
bootstrap plugins (each plugin is an Effect)opinionated/launcher/bootstrap.py

Thunk[T]#

UseLocation
Thunk[Result[T]] = Action[T] (the thunk of an ROP action)dsl/testing_with_railway/internals/action_chain.py
Thunk[ActionChain[T]] in ChainRunnerdsl/testing_with_railway/chain_actions.py
Thunk[bool] for the when conditions of match_pagedsl/testing_with_railway/match_page.py
Thunk[str] for logger.set_prefix(prefix_thunk)ports/ilogger.py
Thunk[ILogger] for logger factoriesdsl/testing/oc_test_suite.py, etc.
Thunk[bool] in the TestCycle dispatch (mode selection)dsl/testing/oc_test_cycle.py

Result[T]#

UseLocation
Return of create_act (via Action[T] = Thunk[Result[T]])dsl/testing_with_railway/constructors/create_act.py
Result of an ActionChaindsl/testing_with_railway/internals/action_chain.py
TestResult = Result[Any] | Nonecustom_types/oc_test_layers.py

Centralization#

Without these aliases, every signature that takes an effect would write Callable[[], None] — visual noise, harder to grep, harder to refactor.

With the aliases:

def run_plugins(*plugins: Effect, exceptions_logger: ILogger) -> None: ...
def setup_fn() -> Effect: return lambda: seed_db()
def factory(...) -> tuple[Effect, ...]: ...

PEP 695 for these aliases#

type Effect = Callable[[], None]                  # PEP 695 (Python 3.12+)
type Thunk[T] = Callable[[], T]                   # PEP 695 (parameterized)
type Result[T] = Ok[T] | Fail                     # PEP 695 (parameterized + union)

Before PEP 695:

from typing import TypeAlias, TypeVar, Callable, Union

Effect: TypeAlias = Callable[[], None]

T = TypeVar("T")
Thunk: TypeAlias = Callable[[], T]                # type error: T isn't in scope
# You had to: Thunk[T] = ...
# But T had to be a global TypeVar

# For Result:
Result: TypeAlias = Union[Ok[T], Fail]            # same

A value, not an effect#

The core idea: these three aliases decouple description from execution:

  • Create an Effect, pass it as an argument → no execution.
  • Create a Thunk[T], compose it with others → no execution.
  • Create a Result[T] inside a function → no exception raised.

Execution happens explicitly at specific points:

  • Effect() to run an effect (the function call).
  • Thunk[T]() same, returns the value.
  • is_fail(result) / is_ok(result) to discriminate.

This description / execution separation is the pillar of lazy evaluation (see 03-lazy-evaluation.md).