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 fetchUserFuture(userId).thenCompose(maybeUser ->
maybeUser
.map(user -> fetchPreferencesFuture(user.id()))
.orElseGet(() -> CompletableFuture.completedFuture(Maybe.nothing())));
}
Each step has to unwrap the Maybe, supply a Nothing fallback wrapped in a completed future, and hand the result back into thenCompose. Every further lookup adds another layer. The pattern is identical to the Optional case but uses Maybe's API.
The Solution
With the Effect Path API
MaybePath carries no outer monad, so it composes lookups that have already resolved:
MaybePath<UserPreferences> getPreferences(String userId) {
return Path.maybe(lookupUser(userId))
.via(user -> Path.maybe(lookupPreferences(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, preferences) -> preferences);
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).