The MaybeT Transformer:
Functional Optionality Across Monads
"Some things are present, others not."
-- Aristotle, Categories
MaybeT is the FP-native cousin of OptionalT. Same idea, same composition story, slightly different host type, and a slightly cleaner pairing with the rest of Higher-Kinded-J.
- How to combine
Maybe's optionality with other monadic effects - Building workflows where operations might produce
Nothingwithin async contexts - Understanding the difference between
MaybeTandOptionalT - Using
Forcomprehensions to keep witness types localised - Using
just,nothing,fromMaybe,liftF, andfromKindto constructMaybeTvalues - When to use the
MaybePathPath type instead of rawMaybeT
For most use cases, MaybePath<A> is the better starting point. It wraps MaybeT in a fluent API, hides the witness types, and removes the Kind widening calls.
Reach for raw MaybeT only when you need to combine absence with a specific outer monad (CompletableFuture, IO, VTask, custom) or when you are writing polymorphic library code that names MonadError<F, Unit>.
The Problem: Nested Async Optionality
When an async lookup returns Maybe rather than Optional, you face the same nesting problem:
CompletableFuture<Maybe<UserPreferences>> getPreferences(String userId) {
return fetchUserAsync(userId).thenCompose(maybeUser ->
maybeUser.fold(
() -> CompletableFuture.completedFuture(Maybe.nothing()),
user -> fetchPreferencesAsync(user.id()).thenCompose(maybePrefs ->
maybePrefs.fold(
() -> CompletableFuture.completedFuture(Maybe.nothing()),
prefs -> CompletableFuture.completedFuture(Maybe.just(prefs))
))
));
}
Each step requires folding over the Maybe, providing a Nothing fallback wrapped in a completed future, and nesting deeper. The pattern is identical to the Optional case but uses Maybe's API.
The Solution
With the Effect Path API
MaybePath<UserPreferences> getPreferences(String userId) {
return Path.maybe(fetchUserAsync(userId))
.via(user -> Path.maybe(fetchPreferencesAsync(user.id())));
}
With raw MaybeT
var futureMonad = Instances.monadError(completableFuture());
var maybeTMonad = Instances.maybeT(futureMonad);
var prefs = For.from(maybeTMonad, MaybeT.fromKind(fetchUserAsync(userId)))
.from(user -> MaybeT.fromKind(fetchPreferencesAsync(user.id())))
.yield((user, prefs) -> prefs);
If fetchUserAsync returns Nothing, the preferences lookup is skipped entirely. No manual folding, no fallback wrapping.
The Railway View
Just ═══●═══════════════●═══════════════════▶ UserPreferences
fetchUser fetchPreferences
(flatMap) (flatMap)
╲ ╲
╲ ╲ Nothing: skip remaining steps
╲ ╲
Nothing ────●────────────────●──────────────────▶ Nothing
user absent prefs absent
│
handleErrorWith provide defaults
│
●═══▶ default UserPreferences
Each flatMap runs inside the outer monad F. If the inner Maybe is Nothing, subsequent steps are skipped. handleErrorWith can provide a fallback value when the chain yields nothing.
How MaybeT Works
MaybeT<F, A> wraps a computation yielding Kind<F, Maybe<A>>. It represents an effectful computation in F that may produce Just(value) or Nothing.
┌──────────────────────────────────────────────────────────┐
│ MaybeT<CompletableFutureKind.Witness, Value> │
│ │
│ ┌─── CompletableFuture ──────────────────────────────┐ │
│ │ │ │
│ │ ┌─── Maybe ───────────────────────────────────┐ │ │
│ │ │ │ │ │
│ │ │ Nothing │ Just(value) │ │ │
│ │ │ │ │ │ │
│ │ └─────────────────────────────────────────────┘ │ │
│ │ │ │
│ └────────────────────────────────────────────────────┘ │
│ │
│ flatMap ──▶ sequences F, then routes on Maybe │
│ map ──────▶ transforms Just(value) only │
│ raiseError(Unit) ──▶ creates Nothing in F │
│ handleErrorWith ──▶ recovers from Nothing │
└──────────────────────────────────────────────────────────┘
F: The witness type of the outer monad (e.g.CompletableFutureKind.Witness,ListKind.Witness).A: The type of the value potentially held by the innerMaybe.
public record MaybeT<F, A>(@NonNull Kind<F, Maybe<A>> value) {
/* ... static factories ... */ }
Setting Up MaybeTMonad
The MaybeTMonad<F> class implements MonadError<MaybeTKind.Witness<F>, Unit>. Like OptionalTMonad, the error type is Unit, signifying that Nothing carries no information beyond its occurrence.
var futureMonad = Instances.monadError(completableFuture());
var maybeTMonad = Instances.maybeT(futureMonad);
Witness Type: MaybeTKind<F, A> extends Kind<MaybeTKind.Witness<F>, A>. The outer monad F is fixed; A is the variable value type.
KindHelper: MaybeTKindHelper provides MAYBE_T.widen and MAYBE_T.narrow for safe conversion. With For comprehensions you rarely need them; they appear at the boundaries when interoperating with raw flatMap chains.
Kind<MaybeTKind.Witness<F>, A> kind = MAYBE_T.widen(maybeT);
MaybeT<F, A> concrete = MAYBE_T.narrow(kind);
Key Operations
| Operation | Behaviour |
|---|---|
maybeTMonad.of(value) | Lifts a nullable value as F<Maybe.fromNullable(value)> |
maybeTMonad.map(f, kind) | Applies A -> B to the Just value; null propagates as Nothing |
maybeTMonad.flatMap(f, kind) | Sequences operations; Nothing short-circuits the rest |
maybeTMonad.raiseError(Unit.INSTANCE) | Creates F<Nothing> |
maybeTMonad.handleErrorWith(kind, handler) | Recovers from Nothing by applying handler |
Creating MaybeT Instances
var optMonad = Instances.monadError(optional());
// 1. From a non-null value: F<Just(value)>
var mtJust = MaybeT.just(optMonad, "Hello");
// 2. Nothing state: F<Nothing>
var mtNothing = MaybeT.<OptionalKind.Witness, String>nothing(optMonad);
// 3. From a plain Maybe: F<Maybe(input)>
var mtFromMaybe = MaybeT.fromMaybe(optMonad, Maybe.just(123));
// 4. Lifting F<A> into MaybeT (using fromNullable)
Kind<OptionalKind.Witness, String> outerOptional = OPTIONAL.widen(Optional.of("World"));
var mtLiftF = MaybeT.liftF(optMonad, outerOptional);
// 5. Wrapping an existing F<Maybe<A>>
Kind<OptionalKind.Witness, Maybe<String>> nestedKind =
OPTIONAL.widen(Optional.of(Maybe.just("Present")));
var mtFromKind = MaybeT.fromKind(nestedKind);
mtJust.value() returns the underlying Kind<F, Maybe<A>>, which you narrow back to the concrete outer monad form when you need the result.
Real-World Example: Async Resource Fetching
The problem: fetch a user asynchronously, and if found, fetch their preferences. Each step might return Nothing. Compose without manual Maybe.fold at every step.
The solution:
var futureMonad = Instances.monadError(completableFuture());
var maybeTMonad = Instances.maybeT(futureMonad);
// Service stubs return Future<Maybe<T>>
Kind<CompletableFutureKind.Witness, Maybe<User>> fetchUserAsync(String userId) {
return FUTURE.widen(CompletableFuture.supplyAsync(() ->
"user123".equals(userId) ? Maybe.just(new User(userId, "Alice"))
: Maybe.nothing()));
}
// Workflow: user -> preferences
var preferences = For.from(maybeTMonad, MaybeT.fromKind(fetchUserAsync(userId)))
.from(user -> MaybeT.fromKind(fetchPreferencesAsync(user.id())))
.yield((user, prefs) -> prefs);
Why this works: the from lambda only executes if the user was found (Just). If fetchUserAsync returns Nothing, the entire chain short-circuits to Future<Nothing>.
Transforming the Outer Monad with mapT
Sometimes you need to change the outer monad of a MaybeT without touching the inner Maybe. Perhaps you have an IO-based pipeline but want to switch to a Task for structured concurrency, or you want to collapse two layers of optionality by moving from Optional<Maybe<A>> to Id<Maybe<A>>.
mapT applies a function to the wrapped Kind<F, Maybe<A>> and produces a new MaybeT<G, A>:
MaybeT< F , A > ── mapT(f) ──> MaybeT< G , A >
│ │
┌────┴────┐ ┌────┴────┐
│ F │ f: F[...] -> G[...] │ G │
│ ┌─────┐ │ ====> │ ┌─────┐ │
│ │Maybe│ │ inner Maybe sealed │ │Maybe│ │
│ │ A │ │ │ │ A │ │
│ └─────┘ │ │ └─────┘ │
└─────────┘ └─────────┘
MaybeT<OptionalKind.Witness, String> optMt = MaybeT.just(optMonad, "Hello");
var idMt = optMt.mapT(optKind -> {
Optional<Maybe<String>> opt = OPTIONAL.narrow(optKind);
return ID.widen(Id.of(opt.orElse(Maybe.nothing())));
});
map transforms the value inside the Maybe (the A in Just(A)).
mapT transforms the outer monad wrapping the Maybe, the F in F<Maybe<A>>.
They operate at different levels of the transformer stack.
MaybeT vs OptionalT: When to Use Which?
Both MaybeT and OptionalT combine optionality with other effects. The functionality is equivalent; the choice depends on your codebase:
| Aspect | MaybeT | OptionalT |
|---|---|---|
| Inner type | Maybe<A> | java.util.Optional<A> |
| Best for | Higher-Kinded-J ecosystem code | Integrating with existing Java code |
| FP-native | Yes (designed for composition) | Wraps Java's standard library |
| Serialisation | No warnings | Identity-sensitive operation warnings |
| Team familiarity | Requires learning Maybe | Uses familiar Optional API |
Use MaybeT when:
- You're working within the Higher-Kinded-J ecosystem and want consistency with
Maybe - You want a type explicitly designed for functional composition
- You want to avoid Java's
Optionaland its quirks (serialisation warnings, identity-sensitive operations)
Use OptionalT when:
- You're integrating with existing Java code that uses
java.util.Optional - Your team is more comfortable with standard Java types
- You're wrapping external libraries that return
Optional
In practice, choose whichever matches your existing codebase. Both offer equivalent functionality through their MonadError instances.
- Confusing
Maybe.nothing()with null:MaybeT.of(null)will useMaybe.fromNullable(null), which producesNothing. Be explicit about intent; useMaybeT.nothing(monad)when you mean absence. - Using
MaybeTwhen you need error information:Nothingcarries no reason for the absence. If you need to know why a value is missing, useEitherTwith a descriptive error type instead. - Reaching for the transformer when
MaybePathwould do: if your outer monad is one Path already wraps,MaybePathis shorter, has less ceremony, and reads more naturally.
- MaybePath - The Path-API equivalent, recommended for most use cases
- Stack Archetypes - The Lookup Stack archetype maps to
MaybeT/MaybePath - Migration Cookbook - Side-by-side translations
- Monad Transformers - General concept and choosing the right transformer
- OptionalT - Equivalent functionality for
java.util.Optional - EitherT - When you need typed errors, not just absence
- Maybe Monad - The underlying Maybe type
- Null Handling Patterns in Modern Java - Comprehensive guide to null safety (15 min read)
The MaybeT exercise lives alongside the OptionalT exercises in Tutorial 02: Async with Absence (5 exercises, ~25 minutes).