The StateT Transformer:

Managing State Across Effect Boundaries

"You could not step twice into the same river."

— Heraclitus

State that changes between steps is exactly the river Heraclitus described. StateT lets each step see the river it actually faces while keeping the same composable surface.

What You'll Learn

  • How to add stateful computation to any existing monad
  • Building stack operations that can fail (StateT with Optional)
  • Understanding the relationship between State and StateT<S, Identity, A>
  • Using For comprehensions with get, put, modify to keep witness types localised
  • When to use the WithStatePath Path type or the MonadState capability instead of raw StateT

Path First, Stack Later

For most use cases, WithStatePath<S, A> is the better starting point when state is the only effect. When you need polymorphic, stack-independent code, the MonadState<F, S> capability is usually a better fit than the concrete StateT.

Reach for raw StateT only when you need to combine state with a specific outer monad that Path does not wrap, or when you are constructing your own MTL instance.


The Problem: Stateful Operations that Can Fail

Imagine a stack data structure where pop might fail on an empty stack. Without StateT, you end up managing both the state transitions and the optionality by hand:

Optional<StateTuple<List<Integer>, Integer>> pop(List<Integer> stack) {
    if (stack.isEmpty()) return Optional.empty();
    var newStack = new LinkedList<>(stack);
    Integer value = newStack.remove(0);
    return Optional.of(StateTuple.of(newStack, value));
}

Optional<StateTuple<List<Integer>, Integer>> workflow(List<Integer> initial) {
    var afterPush1 = push(initial, 10);
    var afterPush2 = push(afterPush1.state(), 20);
    var pop1Result = pop(afterPush2.state());
    if (pop1Result.isEmpty()) return Optional.empty();
    var pop2Result = pop(pop1Result.get().state());
    if (pop2Result.isEmpty()) return Optional.empty();
    int sum = pop1Result.get().value() + pop2Result.get().value();
    return Optional.of(StateTuple.of(pop2Result.get().state(), sum));
}

Each operation returns both a new state and a value; the optionality adds another layer of checking. The state threading is manual and error-prone. Miss one .get().state() call and you use stale state.


The Solution

With the Effect Path API (single effect)

If state is the only effect, WithStatePath is the simplest expression:

WithStatePath<List<Integer>, Integer> workflow() {
    return WithStatePath.<List<Integer>>modify(s -> prepend(s, 10))
        .then(() -> WithStatePath.<List<Integer>>modify(s -> prepend(s, 20)))
        .then(() -> WithStatePath.<List<Integer>>get())
        .map(state -> state.get(0) + state.get(1));
}

With raw StateT (combined effect)

When state must combine with another effect (here Optional):

var optMonad = Instances.monadError(optional());
// Name the state: nothing else constrains S, and it would otherwise infer to Object.
var stateTMonad =
    Instances.<List<Integer>, OptionalKind.Witness>stateT(optMonad);

var computation = For.from(stateTMonad, push(10))
    .from(_ -> push(20))
    .from(_ -> pop())
    .from(_ -> pop())
    .yield((a, b, p1, p2) -> p1 + p2);

var result = OPTIONAL.narrow(STATE_T.runStateT(computation, Collections.emptyList()));
// → Optional.of(StateTuple([], 30))

The state flows from one operation to the next through flatMap. If any operation returns Optional.empty() (e.g. popping an empty stack), the rest are skipped. No manual state passing, no null checks.


The Railway View

    Value   ═══●═══════════●═══════════●═══════════●═══▶  result A (in F)
               push(10)    push(20)    pop         pop
               (flatMap)   (flatMap)   (flatMap)   (flatMap)
               │           │           │           │
               ▼           ▼           ▼           ▼
    State   ═══●═══════════●═══════════●═══════════●═══▶  final state S
               [10]        [20,10]     [10]        []

Both tracks advance in lockstep: each flatMap produces a new (value, state) pair. Calling runStateT(initialState) at the boundary kicks the whole computation off and yields the final state alongside the result. If the outer monad F short-circuits (here Optional.empty() on an empty pop), subsequent steps are skipped and both tracks freeze.


How StateT Works

StateT<S, F, A> represents a computation that takes an initial state S, produces a result A and a new state S, all within the context of a monad F.

    ┌──────────────────────────────────────────────────────────┐
    │  StateT<List<Integer>, OptionalKind.Witness, A>          │
    │                                                          │
    │    State S ─────▶ ┌────────────────────────┐             │
    │    (initial)      │  Function:             │             │
    │                   │  S → Kind<F, (S, A)>   │             │
    │                   └────────────┬───────────┘             │
    │                                │                         │
    │                                ▼                         │
    │                   ┌─── Optional ──────────┐              │
    │                   │                       │              │
    │                   │  empty()  │  of(S, A) │              │
    │                   │           │           │              │
    │                   └───────────────────────┘              │
    │                                                          │
    │  flatMap ──▶ threads updated state to next operation     │
    │  map ──────▶ transforms value, state unchanged           │
    │  runStateT ──▶ provides initial state, returns F<(S,A)>  │
    │  evalStateT ──▶ returns F<A> (discards final state)      │
    │  execStateT ──▶ returns F<S> (discards value)            │
    └──────────────────────────────────────────────────────────┘
  • S: The type of the state.
  • F: The witness type for the underlying monad (e.g. OptionalKind.Witness, IOKind.Witness).
  • A: The type of the computed value.
  • StateTuple<S, A>: A container holding the pair (state, value).

The fundamental structure is a function S -> F<StateTuple<S, A>>:

StateT<Integer, OptionalKind.Witness, String> computation = StateT.create(
    currentState -> currentState < 0
        ? OPTIONAL.widen(Optional.empty())
        : OPTIONAL.widen(Optional.of(StateTuple.of(currentState + 1, "Value: " + currentState))));

Setting Up StateTMonad

The StateTMonad<S, F> class implements Monad<StateTKind.Witness<S, F>>. It requires a Monad<F> instance for the underlying monad:

var optionalMonad = Instances.monadError(optional());
var stateTMonad   =
    Instances.<Integer, OptionalKind.Witness>stateT(optionalMonad);

Working with Kind

  • StateT<S, F, A>: the primary data type holding S -> Kind<F, StateTuple<S, A>>.
  • StateTKind<S, F, A>: the Kind representation for generic monadic usage.
  • StateTKind.Witness<S, F>: the higher-kinded type witness. Both S and F are part of the witness.
  • StateTMonad<S, F>: the Monad instance, providing of, map, flatMap, ap.
  • StateTKindHelper: utility for narrow, runStateT, evalStateT, execStateT.
  • StateTuple<S, A>: a record holding (S state, A value).

Running StateT Computations

// Run: returns F<StateTuple<S, A>>
var result    = STATE_T.runStateT(computation, 10);
// → Optional.of(StateTuple(11, "Value: 10"))

// Eval: returns F<A> (discards state)
var valueOnly = STATE_T.evalStateT(computation, 10, optionalMonad);
// → Optional.of("Value: 10")

// Exec: returns F<S> (discards value)
var stateOnly = STATE_T.execStateT(computation, 10, optionalMonad);
// → Optional.of(11)

Why eval and exec take the Monad

A StateT holds only its state function, not a Monad<F>. runStateT just applies that function, so it needs nothing more. evalStateT and execStateT map over the F result to keep one half of the pair, so they take the Monad<F> as their last argument. Keeping the monad out is what makes two StateT values wrapping the same function instance equal.


Key Operations

OperationBehaviour
stateTMonad.of(value)Wraps a pure value, leaving state unchanged
stateTMonad.map(f, kind)Transforms the value; state passes through
stateTMonad.flatMap(f, kind)Sequences operations, threading the updated state

The MonadState capability adds get(), put(s), modify(f), gets(f), and inspect(f) on top.


Composing StateT Actions

Like any monad, StateT computations compose with map and flatMap. Most pages in this chapter show this through For comprehensions; the explicit forms are equivalent:

map: transforming the value

var initial = StateT.<Integer, OptionalKind.Witness, Integer>create(
    s -> OPTIONAL.widen(Optional.of(StateTuple.of(s + 1, s * 2))));

var mapped = stateTMonad.map(val -> "Computed: " + val, initial);

// Run with state 5: initial → state=6, value=10; map → "Computed: 10"
// → Optional.of(StateTuple(6, "Computed: 10"))

flatMap: sequencing state operations

var firstStep = StateT.<Integer, OptionalKind.Witness, Integer>create(
    s -> OPTIONAL.widen(Optional.of(StateTuple.of(s + 1, s * 10))));

Function<Integer, Kind<StateTKind.Witness<Integer, OptionalKind.Witness>, String>> secondStepFn =
    prevValue -> StateT.create(
        s -> prevValue > 100
            ? OPTIONAL.widen(Optional.of(StateTuple.of(s + prevValue, "Large: " + prevValue)))
            : OPTIONAL.widen(Optional.empty()));

var combined = stateTMonad.flatMap(secondStepFn, firstStep);
// state 15: firstStep → (16, 150), secondStep(150) → (166, "Large: 150")
// state 5:  firstStep → (6, 50),  secondStep(50)  → empty

State-Specific Operations

Common state operations can be constructed using StateT.create:

// get: retrieve the current state as the value
static <S, F extends WitnessArity<TypeArity.Unary>>
    Kind<StateTKind.Witness<S, F>, S> get(Monad<F> monadF) {
  return StateT.create(s -> monadF.of(StateTuple.of(s, s)));
}

// set: replace the state, return Unit
static <S, F extends WitnessArity<TypeArity.Unary>>
    Kind<StateTKind.Witness<S, F>, Unit> set(S newState, Monad<F> monadF) {
  return StateT.create(s -> monadF.of(StateTuple.of(newState, Unit.INSTANCE)));
}

// modify: update the state with a function, return Unit
static <S, F extends WitnessArity<TypeArity.Unary>>
    Kind<StateTKind.Witness<S, F>, Unit> modify(Function<S, S> f, Monad<F> monadF) {
  return StateT.create(s -> monadF.of(StateTuple.of(f.apply(s), Unit.INSTANCE)));
}

// gets: extract a value derived from the state
static <S, F extends WitnessArity<TypeArity.Unary>, A>
    Kind<StateTKind.Witness<S, F>, A> gets(Function<S, A> f, Monad<F> monadF) {
  return StateT.create(s -> monadF.of(StateTuple.of(s, f.apply(s))));
}

Real-World Example: Stack with Failure

Stack Operations with Optional Failure

The problem: stack push/pop operations where popping an empty stack produces an absence rather than an exception. Compose them cleanly.

The solution:

private static final MonadError<OptionalKind.Witness, Unit> OPT_MONAD =
    Instances.monadError(optional());
private static final Monad<StateTKind.Witness<List<Integer>, OptionalKind.Witness>> ST_OPT_MONAD =
    Instances.stateT(OPT_MONAD);

static Kind<StateTKind.Witness<List<Integer>, OptionalKind.Witness>, Unit> push(Integer value) {
  return STATE_T.stateT(stack -> {
      var newStack = new LinkedList<>(stack);
      newStack.add(0, value);
      return OPTIONAL.widen(Optional.of(StateTuple.of(newStack, Unit.INSTANCE)));
  });
}

static Kind<StateTKind.Witness<List<Integer>, OptionalKind.Witness>, Integer> pop() {
  return STATE_T.stateT(stack -> {
      if (stack.isEmpty()) return OPTIONAL.widen(Optional.empty());
      var newStack = new LinkedList<>(stack);
      Integer popped = newStack.remove(0);
      return OPTIONAL.widen(Optional.of(StateTuple.of(newStack, popped)));
  });
}

// Compose with For:
var computation = For.from(ST_OPT_MONAD, push(10))
    .from(_ -> push(20))
    .from(_ -> pop())
    .from(_ -> pop())
    .yield((a, b, p1, p2) -> p1 + p2);

var result = OPTIONAL.narrow(STATE_T.runStateT(computation, Collections.emptyList()));
// → Optional.of(StateTuple([], 30))

var emptyPop = OPTIONAL.narrow(STATE_T.runStateT(pop(), Collections.emptyList()));
// → Optional.empty()

Why this works: the For comprehension sequences state operations through flatMap. Each push returns the updated stack as new state; each pop either returns the popped value with an updated stack or Optional.empty(), which short-circuits the rest. The state threading is completely automatic.


Transforming the Outer Monad with mapT

Sometimes you need to change the outer monad of a StateT without touching the state-threading logic. Perhaps you want to switch from Optional to Id (guaranteeing a result with a default), or apply a natural transformation to move between effect types.

Because StateT wraps a function rather than a value, mapT composes the transformation function after each state transition:

  state ──> runStateTFn() ──> Kind<F, StateTuple<S, A>> ──> f ──> Kind<G, StateTuple<S, A>>
    │                                                                        │
    └──── combined into new StateT<S, G, A> ────────────────────────────────┘
// optStateT is a StateT<Integer, OptionalKind.Witness, String>
var idStateT = optStateT.mapT(optKind -> {
  Optional<StateTuple<Integer, String>> opt = OPTIONAL.narrow(optKind);
  return ID.widen(Id.of(opt.orElse(StateTuple.of(0, "default"))));
});

mapT vs map

map transforms the value produced by the state computation (the A in StateTuple<S, A>). mapT transforms the outer monad wrapping each state transition, the F in S -> F<StateTuple<S, A>>. The state-threading is completely unaffected.


Relationship to State Monad

The State Monad (State<S, A>) is a specialised case of StateT. Specifically, State<S, A> is equivalent to StateT<S, IdKind.Witness, A>, where Id is the Identity monad (a monad that adds no effects).

If your stateful computation does not need to combine with another effect, use State<S, A> directly (or WithStatePath<S, A>). Reach for StateT when you need state and another effect (optionality, error handling, async).


Common Mistakes

  • Using stale state: in manual state threading, it is easy to accidentally use the state from step 1 in step 3. StateT.flatMap eliminates this by threading updated state automatically.
  • Null in ap: the ap method requires the function it extracts from the first StateT computation to be non-null. A null function will cause a NullPointerException.
  • Confusing StateT with ReaderT: if your "state" never changes, you probably want ReaderT. Use StateT only when operations need to modify the state.
  • Reaching for the transformer when WithStatePath would do: if state is your only effect, WithStatePath is shorter and reads more naturally.

See Also

Hands-On Learning

The MonadState capability that wraps StateT is exercised in Tutorial 04: Polymorphic Capabilities (MTL) (14 exercises).


Previous: ReaderT Next: WriterT