The WriterT Transformer:
Accumulating Output Across Effect Boundaries
"The writer's duty is to keep on writing."
-- William Styron
A writer accumulates, sentence by sentence, until a complete work emerges. WriterT does the same for your computations: each step appends to an output log, and the accumulation travels invisibly through whatever outer effect you are working in.
- How to add output accumulation (logging, audit trails, diagnostics) to any monad
- Building workflows that record every step without threading a mutable log
- Understanding the
Monoidrequirement and how it controls output combination - Using
Forcomprehensions withtell,listen,pass, andcensor - The relationship between
WriterandWriterT<F, W, A> - When to use the
WriterPathPath type or theMonadWritercapability instead of rawWriterT
For most use cases, WriterPath<W, A> is the better starting point when accumulating output is the only effect. When you need polymorphic, stack-independent code, the MonadWriter<F, W> capability is usually a better fit than the concrete WriterT.
Reach for raw WriterT only when you need to combine accumulation with a specific outer monad that Path does not wrap, or when you are constructing your own MTL instance.
The Problem: Logs That Vanish Into Outer Effects
Consider an async pipeline that needs an audit trail:
CompletableFuture<Pair<BigDecimal, List<String>>> applyDiscount(
BigDecimal price, List<String> logSoFar) {
return CompletableFuture.supplyAsync(() -> {
var discounted = price.multiply(new BigDecimal("0.9"));
var newLog = new ArrayList<>(logSoFar);
newLog.add("Applied 10% discount");
return Pair.of(discounted, newLog);
});
}
CompletableFuture<Pair<BigDecimal, List<String>>> addShipping(
BigDecimal price, List<String> logSoFar) {
return CompletableFuture.supplyAsync(() -> {
var withShipping = price.add(new BigDecimal("5.00"));
var newLog = new ArrayList<>(logSoFar);
newLog.add("Added shipping");
return Pair.of(withShipping, newLog);
});
}
Every function must accept a log, copy it, append to it, and return it alongside the result. The log leaks into every signature. Composition requires manual log threading at every call site. Miss one handoff and entries disappear.
The Solution
With the Effect Path API (single effect)
If accumulation is the only effect, WriterPath is the simplest expression:
WriterPath<List<String>, BigDecimal> workflow(BigDecimal price) {
return WriterPath.<List<String>, BigDecimal>writer(
price.multiply(new BigDecimal("0.9")),
List.of("Applied 10% discount"),
listMonoid)
.via(discounted -> WriterPath.writer(
discounted.add(new BigDecimal("5.00")),
List.of("Added shipping"),
listMonoid));
}
With raw WriterT (combined effect)
When accumulation must combine with another monad (here Id for a pure example, but the same shape works over CompletableFuture):
var idMonad = Instances.monad(id());
var listMonoid = Monoids.list();
var writerMonad = Instances.writerT(idMonad, listMonoid);
var workflow = For.from(writerMonad, writerMonad.tell(List.of("Applied 10% discount")))
.from(_ -> writerMonad.of(new BigDecimal("90.00")))
.from(p -> writerMonad.tell(List.of("Added shipping")))
.yield((_, price, _) -> price.add(new BigDecimal("5.00")));
One comprehension. The log combines via List's Monoid. No manual threading.
The Railway View
Value ═══●═══════════════●═══════════════●═══▶ final price (in F)
applyDiscount addShipping yield
(flatMap) (flatMap)
│ │
▼ tell ▼ tell
Log ──●──────────────●──────────────────────▶ ["Discount", "Shipping"]
"Applied 10%" "Added shipping"
combined via Monoid<List<String>>
The value track and the log track advance together. Each tell appends to the log without interrupting the computation; flatMap combines accumulated logs through the supplied Monoid. The final Kind<F, Pair<A, W>> carries both the result and the full audit trail.
How WriterT Works
WriterT<F, W, A> wraps Kind<F, Pair<A, W>>. The outer monad F provides the computational context (async, optional, error-handling); the Pair<A, W> carries both the computed value and the accumulated output.
┌──────────────────────────────────────────────────────────┐
│ WriterT<CompletableFutureKind.Witness, List<String>, A> │
│ │
│ Outer monad: CompletableFuture (async execution) │
│ Output type: List<String> (audit entries) │
│ Value type: A (computation result) │
│ │
│ Internally wraps: │
│ CompletableFuture<Pair<A, List<String>>> │
│ │
│ flatMap ──▶ sequences steps, combines logs via Monoid │
│ tell ────▶ appends entries, returns Unit │
│ listen ──▶ exposes accumulated log alongside result │
│ censor ──▶ transforms the log (e.g. filter sensitive) │
└──────────────────────────────────────────────────────────┘
F: The witness type of the outer monad (e.g.IdKind.Witness,CompletableFutureKind.Witness).W: The output type that accumulates. Must have aMonoid<W>instance.A: The type of the computed value.run(): returns the wrappedKind<F, Pair<A, W>>.
public record WriterT<F, W, A>(Kind<F, Pair<A, W>> run)
implements WriterTKind<F, W, A> {
// ... static factory methods ...
}
Setting Up WriterTMonad
The WriterTMonad<F, W> class implements both Monad and MonadWriter, providing monadic operations and output accumulation. It requires a Monad<F> for the outer monad and a Monoid<W> for combining outputs:
Monoid<String> stringMonoid = new Monoid<>() {
public String empty() { return ""; }
public String combine(String a, String b) { return a + b; }
};
var idMonad = Instances.monad(id());
var writerMonad = Instances.writerT(idMonad, stringMonoid);
Witness Type: WriterTKind<F, W, A> extends Kind<WriterTKind.Witness<F, W>, A>. The outer monad F and output type W are fixed; A is the variable value type.
KindHelper: WriterTKindHelper provides WRITER_T.widen and WRITER_T.narrow for safe conversion. With For comprehensions you rarely need them; they appear at the boundaries when interoperating with raw flatMap chains.
Kind<WriterTKind.Witness<F, W>, A> kind = WRITER_T.widen(writerT);
WriterT<F, W, A> concrete = WRITER_T.narrow(kind);
The Monoid Requirement
The Monoid<W> determines how output from successive steps is combined. This is the engine that makes automatic log accumulation work:
| Monoid | empty() | combine(a, b) | Use Case |
|---|---|---|---|
String | "" | a + b | Simple text logs |
List<T> | [] | concatenation | Structured audit entries |
Integer (sum) | 0 | a + b | Counting operations |
Set<T> | {} | union | Collecting unique tags |
Without a Monoid, WriterT cannot combine the output from flatMap chains. The Monoid is what makes the accumulation lawful: combine(empty(), w) == w, combine(w, empty()) == w, and combine is associative.
Key Operations
| Operation | Behaviour |
|---|---|
of(value) | Lifts a pure value with empty output as F<Pair(value, empty)> |
map(f, kind) | Transforms the value; output preserved unchanged |
flatMap(f, kind) | Sequences computations; combines outputs via the Monoid |
tell(w) | Appends w to the output, returns Unit |
listen(kind) | Runs the computation and returns the output alongside the result |
pass(kind) | Computation returns Pair(a, f); applies f to transform the output |
listens(f, kind) | Like listen but maps f over the accumulated output in the pair |
censor(f, kind) | Modifies the accumulated output without seeing the result |
Creating WriterT Instances
var idMonad = Instances.monad(id());
Monoid<String> stringMonoid = /* as above */;
// 1. Pure value with empty output
var pure = WriterT.of(idMonad, stringMonoid, 42);
// → Pair(42, "")
// 2. Record output with no meaningful value
var logged = WriterT.tell(idMonad, "initialised; ");
// → Pair(Unit.INSTANCE, "initialised; ")
// 3. Lift an outer-monad value with empty output
Kind<IdKind.Witness, Integer> idValue = IdKindHelper.ID.widen(new Id<>(42));
var lifted = WriterT.liftF(idMonad, stringMonoid, idValue);
// → Pair(42, "")
// 4. Explicit value and output
var explicit = WriterT.writer(idMonad, 42, "created; ");
// → Pair(42, "created; ")
// 5. From an existing Kind<F, Pair<A, W>>
var fromKind = WriterT.fromKind(idMonad.of(Pair.of(42, "restored; ")));
// → Pair(42, "restored; ")
Real-World Example: Audit Trail
The problem: a multi-step order processing pipeline must record every decision for compliance. The log must travel with the computation, not sit in a mutable side channel.
The solution:
var idMonad = Instances.monad(id());
var listMonoid = Monoids.list();
var audit = Instances.writerT(idMonad, listMonoid);
var workflow = For.from(audit, audit.tell(List.of("Validated order")))
.from(_ -> audit.of("order-123"))
.from(orderId -> audit.tell(List.of("Applied 10% discount to " + orderId)))
.from((_, _, _) -> audit.of(new BigDecimal("90.00")))
.from(amount -> audit.tell(List.of("Charged " + amount)))
.yield((_, _, _, _, _) -> "receipt-456");
var concrete = WRITER_T.narrow(workflow);
var pair = IdKindHelper.ID.narrow(concrete.run()).value();
pair.first(); // → "receipt-456"
pair.second(); // → ["Validated order",
// "Applied 10% discount to order-123",
// "Charged 90.00"]
Why this works: each tell appends entries. Each from step combines outputs via the List monoid. The audit trail is complete, ordered, and immutable. No step can forget to pass the log forward; WriterT handles it.
Inspecting and Transforming Output
listen: see what was written
listen runs a computation and returns the result paired with the output that computation produced:
var computation = For.from(audit, audit.tell(List.of("computed value")))
.yield(_ -> 42);
var listened = audit.listen(computation);
// → Pair(Pair(42, ["computed value"]), ["computed value"])
// result paired with output output preserved
This is useful for conditional logic based on what was logged.
censor: redact sensitive output
censor applies a function to the output without seeing the result:
var withSensitiveData = For.from(audit, audit.tell(List.of("API key: sk_live_abc123")))
.yield(_ -> "done");
var redacted = audit.censor(
entries -> entries.stream()
.map(e -> e.contains("API key") ? "API key: [REDACTED]" : e)
.toList(),
withSensitiveData);
// Output: ["API key: [REDACTED]"]
Transforming the Outer Monad with mapT
Sometimes you need to change the outer monad of a WriterT without touching the accumulated output. Perhaps you want to wrap an Id-based writer into an Optional context for a downstream API, or switch effect types via a natural transformation.
mapT applies a function to the wrapped Kind<F, Pair<A, W>> and produces a new WriterT<G, W, A>:
WriterT< F , W, A > ── mapT(f) ──> WriterT< G , W, A >
│ │
┌────┴────┐ ┌────┴────┐
│ F │ f: F[...] -> G[...] │ G │
│ ┌─────┐ │ ====> │ ┌─────┐ │
│ │Pair │ │ inner Pair untouched │ │Pair │ │
│ │ A,W │ │ │ │ A,W │ │
│ └─────┘ │ │ └─────┘ │
└─────────┘ └─────────┘
var idWriter = WriterT.writer(idMonad, "result", List.of("step 1", "step 2"));
var optWriter = idWriter.mapT(idKind -> {
Pair<String, List<String>> pair = ID.unwrap(idKind);
return OPTIONAL.widen(Optional.of(pair));
});
map transforms the value inside the Pair (the A in Pair<A, W>).
mapT transforms the outer monad wrapping the Pair, the F in F<Pair<A, W>>.
The accumulated output W is completely unaffected.
Relationship to Writer
Writer<W, A> is the non-transformer version: it pairs a value with accumulated output directly, with no outer monad. WriterT<IdKind.Witness, W, A> is equivalent to Writer<W, A>, the identity monad adds no additional effect.
Writer<W, A> ≡ WriterT<Id, W, A>
Writer<W, A> WriterT<F, W, A>
┌───────────────┐ ┌────────────────────────────┐
│ Pair(A, W) │ │ Kind<F, Pair(A, W)> │
│ │ │ │
│ No outer │ │ Outer monad adds: │
│ effect │ │ async / optional / error │
└───────────────┘ └────────────────────────────┘
Use Writer when you need output accumulation in pure code. Use WriterT when you need output accumulation combined with another effect.
- Forgetting the Monoid:
WriterTrequires aMonoid<W>at construction. If you passnull, you get aNullPointerException. If your monoid'scombineis incorrect (not associative), law tests will fail. - Large accumulated output: unlike streaming,
WriterTaccumulates the entire output in memory. For high-volume logging, considerVStreamwith a logging side effect instead. - Using
WriterTwhen you need state:WriterTis append-only. You cannot read the accumulated output mid-computation (uselistento observe it). If you need to read and modify state, useStateT. - Reaching for the transformer when
WriterPathwould do: if accumulation is your only effect,WriterPathis shorter and reads more naturally.
- WriterPath / Advanced Effects - The Path-API equivalent
- MonadWriter - The MTL capability for stack-independent code
- Stack Archetypes - The Audit Stack archetype maps to
WriterT/WriterPath - Migration Cookbook - Side-by-side translations
- Writer Monad - The non-transformer version for pure computations
- Monad Transformers - General concept and choosing the right transformer
- StateT - When you need read-write state, not append-only output
- ReaderT - When you need read-only environment access
The MonadWriter capability that wraps WriterT is exercised in Tutorial 04: Polymorphic Capabilities (MTL) (14 exercises, ~30-40 minutes).
Previous: StateT Next: MTL Capabilities