Skip to content

M06C07: Container Layering – Combining Effects Without Monad Transformers

Progression Note

Module 6 shifts from pure data modelling to effect-aware composition.
We now treat failure and absence as first-class effects that propagate automatically through pipelines — eliminating nested conditionals forever.

Module Focus Key Outcomes
5 Algebraic Data Modelling ADTs, exhaustive pattern matching, total functions, refined types
6 Monadic Flows as Composable Pipelines bind/and_then, Reader/State-like patterns, error-typed flows
7 Effect Boundaries & Resource Safety Dependency injection, boundaries, testing, evolution

Core question
How do you combine multiple effects (config + failure, state + failure, failure + absence) in a single pipeline using only simple nested containers and two transpose helpers — with zero monad transformers and zero category theory?

This is the core that shows you how to stack the containers from earlier cores into real production pipelines. No magic, no heavy abstraction — just nesting and deliberate layer order.

Audience: Engineers who have Reader, State, Result, and Option working individually and now need to combine them without introducing complexity.

Outcome 1. You will confidently nest containers like Reader[Config, Result[T, E]] or State[S, Result[T, E]]. 2. You will understand exactly how layer order controls short-circuit and resource usage. 3. You will know when (and when not) to transpose layers.

Layer Order Controls Everything

Note: “outer” / “inner” here are about effect dominance in the type
(who owns the short-circuit behaviour), not literal Python call order.

Outer → Inner Short-circuit behaviour State/Config on failure? Recommended for
Result → anything Err stops everything immediately No Most pipelines (failure dominates)
Reader → Result Config always available (even for logging Err) Yes (config) When you need config on error paths
State → Result State always threaded (even on Err) Yes (state) When you want to count failed operations
Result → Reader/State Err short-circuits before Reader/State runs No When config/state only needed on success
Result → Option Err dominates absence Default for "failure or not found"
Option → Result None dominates Err (very rare) Only when absence should win

Rule of thumb: Put Result outermost in 95% of cases.
It gives true early short-circuit and prevents work on failure.

When you need config or state on error paths (e.g. logging, metrics), put Reader/State outside Result.

1. Laws & Invariants (machine-checked in CI)

Law Formal Statement Why it matters
Stacked Monad Laws Same left/right/associativity as single containers (via normal combinators) Refactor-safe multi-effect pipelines
Transpose Involution transpose_option_result(transpose_result_option(m)) == m Swapping layers twice is identity
Error Dominance transpose_result_option(Err(e)) == Some(Err(e)) Error beats absence

The stacked monad laws are verified in the full test suite. The transpose-specific laws are shown below.

2. Public API – Only two transpose helpers (everything else is plain nesting)

# src/funcpipe_rag/fp/effects/layering.py – end-of-Module-06 (mypy --strict clean target)

from __future__ import annotations
from funcpipe_rag.result.types import Result, Ok, Err, Option, Some, NoneVal

T = TypeVar("T")
E = TypeVar("E")

def transpose_result_option(ro: Result[Option[T], E]) -> Option[Result[T, E]]:
    """Swap layers: Result[Option[T]] → Option[Result[T]].

    Error dominates absence: Err(e) becomes Some(Err(e)).
    """
    if isinstance(ro, Err):
        return Some(Err(ro.error))
    return Some(Ok(ro.value.value)) if isinstance(ro.value, Some) else NoneVal()

def transpose_option_result(or_: Option[Result[T, E]]) -> Result[Option[T], E]:
    """Swap layers: Option[Result[T]] → Result[Option[T]].

    Error dominates absence: Some(Err(e)) becomes Err(e).
    """
    if isinstance(or_, NoneVal):
        return Ok(NoneVal())
    return Err(or_.value.error) if isinstance(or_.value, Err) else Ok(Some(or_.value.value))

That's it. No other cross-layer helpers required.
Just nest containers and use the usual .map / .and_then.
(You may occasionally write tiny local lift functions for ergonomics in very deep stacks — but 99% of the time you won’t need them.)

3. Real-World Example – Query User (Failure + Absence)

def query_user(id: UserId) -> Result[Option[User], NetworkErr]:
    """Most common shape: Result outer → network failure short-circuits early."""
    resp = try_result(
        lambda: http_get(f"/users/{id}"),
        map_http_exc,
    )
    return resp.map(lambda body: NoneVal() if body is None else Some(parse_user(body)))

# Usage – linear, no manual nesting
match query_user(some_id):
    case Ok(Some(user)): process(user)
    case Ok(NoneVal()): handle_not_found()
    case Err(e): handle_network_error(e)   # no user parsing work done

If you ever need absence to dominate (very rare):

absent_first = transpose_result_option(query_user(id))  # Option[Result[User, NetworkErr]]
Goal Stack (outer → inner) Rationale
Failure dominates everything Result[Reader[Config, State[PipelineState, T]], ErrInfo] Early exit on error when interpreted as (Config → State → Result[T, ErrInfo])
Config needed on error paths Reader[Config, Result[State[PipelineState, T], ErrInfo]] Config available for logging/metrics on Err
Count failed operations State[PipelineState, Result[Reader[Config, T], ErrInfo]] State threaded even on Err

Pick the type layer order that matches your dominance needs.

5. Before → After – Nested Container Handling

# BEFORE – manual nesting hell
def get_user(id: UserId) -> User | None:
    res = http_get(id)                  # Result[bytes, NetworkErr]
    if isinstance(res, Err):
        log(res.error)
        return None                     # conflates network error with not found
    if not res.value:
        return None
    return parse_user(res.value)

# AFTER – typed layering, no manual checks
def get_user(id: UserId) -> Result[Option[User], NetworkErr]:
    return (
        try_result(lambda: http_get(id), map_http_exc)
        .map(lambda body: NoneVal() if body is None else Some(parse_user(body)))
    )

match get_user(some_id):
    case Ok(Some(user)): process(user)
    case Ok(NoneVal()): handle_not_found()
    case Err(e): handle_network_error(e)

Zero manual propagation. Types tell the whole story.

6. Property-Based Proofs (selected)

@given(ro=results(values=options()))
def test_transpose_result_option_involution(ro):
    assert transpose_option_result(transpose_result_option(ro)) == ro

@given(or_=options(values=results()))
def test_transpose_option_result_involution(or_):
    assert transpose_result_option(transpose_option_result(or_)) == or_

@given(e=errors())
def test_transpose_result_option_error_dominates(e):
    assert transpose_result_option(Err(e)) == Some(Err(e))

@given(e=errors())
def test_transpose_option_result_error_dominates(e):
    assert transpose_option_result(Some(Err(e))) == Err(e)

7. Anti-Patterns & Immediate Fixes

Anti-Pattern Symptom Fix
Manual nested match/if Deeply indented error handling Use layered containers + normal combinators
Wrong layer order Unexpected short-circuit behaviour Choose order deliberately (or transpose)
Monad transformers Category-theory overhead Just nest — Python handles it

8. Pre-Core Quiz

  1. Default recommended outer container? → Result (failure dominates)
  2. When to put Reader outer? → When config needed on error paths
  3. Transpose is needed for? → Result ↔ Option only
  4. Recommended production stack when counting failed ops? → State outer, Result inner
  5. The golden rule? → Let layer order do the work — no transformers needed

9. Post-Core Exercise

  1. Take a real pipeline with both failure and absence — rewrite with Result[Option[T]].
  2. Swap to Option[Result[T]] with transpose — confirm propagation changes.
  3. Add Reader or State to an existing layered pipeline — choose order deliberately.

Next: M06C08 – Writer Pattern – Accumulating Logs/Metrics as Pure Data

You have now combined all effect containers into production-ready pipelines using nothing more than simple nesting and deliberate layer order. Your code is pure, composable, and proven correct by Hypothesis — with zero category-theory overhead. The remaining cores are pure polish.