How to Use Do-Notation

This guide shows you how to use the @do decorator to combine several monadic values in a readable, imperative style — without writing nested lambda chains.

Prerequisites

Basic structure

A do-block is a generator function decorated with @do(M). Each yield extracts the wrapped value; return produces the plain result, which the decorator lifts into the monad automatically.

from katharos.syntax_sugar import do, DoBlock
from katharos.types import Maybe

@do(Maybe)
def block() -> DoBlock[int]:
    x: int = yield Maybe[int].Just(10)
    y: int = yield Maybe[int].Just(5)
    return x + y

block()  # Just(15)
  • @do(Maybe) — declares which monad the block works with. Every yield must produce a value of that monad type.

  • DoBlock[int] — the return type annotation for the generator. The type argument is the plain return type; the decorator lifts it into the monad.

  • x: int = yield Maybe[int].Just(10) — extracts the wrapped value and binds it to x. Annotate the type inline because Python’s Generator cannot infer per-yield types automatically.

Short-circuit behaviour

When any yield step holds a Nothing() or Failure, the entire block short-circuits and the decorator returns that failure immediately:

@do(Maybe)
def block() -> DoBlock[int]:
    x: int = yield Maybe[int].Just(10)
    y: int = yield Maybe[int].Nothing()   # short-circuits here
    return x + y

block()  # Nothing()

Using a bound value in a subsequent yield

Because yield returns the real unwrapped value — not a placeholder — you can use it immediately in the next line, including passing it to another monadic function:

def lookup(key: str) -> Maybe[str]:
    db = {"alice": "admin", "bob": "user"}
    return Maybe[str].Just(db[key]) if key in db else Maybe[str].Nothing()

def greet(role: str) -> Maybe[str]:
    if role == "admin":
        return Maybe[str].Just("Hello, Admin!")
    return Maybe[str].Nothing()

@do(Maybe)
def block() -> DoBlock[str]:
    role:   str = yield lookup("alice")
    result: str = yield greet(role)   # role is "admin" here — greet returns Maybe
    return result

block()  # Just('Hello, Admin!')

When a step already returns a Maybe (or whichever monad you are using), just yield its result directly. No special eval call is needed.

Using with Result

The same pattern works with Result:

from katharos.types import Result

def parse_int(s: str) -> Result[Exception, int]:
    try:
        return Result[Exception, int].Success(int(s))
    except ValueError as e:
        return Result[Exception, int].Failure(e)

def safe_divide(a: int, b: int) -> Result[Exception, float]:
    if b == 0:
        return Result[Exception, float].Failure(ZeroDivisionError("division by zero"))
    return Result[Exception, float].Success(a / b)

@do(Result)
def block() -> DoBlock[float]:
    a:      int   = yield parse_int("20")
    b:      int   = yield parse_int("4")
    result: float = yield safe_divide(a, b)
    return result

block()  # Success(5.0)

Sequencing without capturing a value

yield a monadic value without assigning the result when you only need the short-circuit behaviour and do not use the unwrapped value:

def validate_positive(n: int) -> Maybe[int]:
    return Maybe[int].Just(n) if n > 0 else Maybe[int].Nothing()

@do(Maybe)
def block() -> DoBlock[int]:
    yield validate_positive(5)       # must succeed; value unused
    x: int = yield Maybe[int].Just(100)
    return x * 2

block()  # Just(200)