The MaybeT Transformer:
Functional Optionality Across Monads
- How to combine Maybe's optionality with other monadic effects
- Building workflows where operations might produce Nothing within async contexts
- Understanding the difference between MaybeT and OptionalT
- Using
just,nothing, andfromMaybeto construct MaybeT values - Handling Nothing states with Unit as the error type in MonadError
The Problem: Nested Async Optionality
When an async lookup returns Maybe rather than Optional, you face the same nesting problem:
// Without MaybeT: manual nesting
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: MaybeT
// With MaybeT: flat composition
Kind<MaybeTKind.Witness<CompletableFutureKind.Witness>, UserPreferences>
getPreferences(String userId) {
var userMT = MAYBE_T.widen(MaybeT.fromKind(fetchUserAsync(userId)));
return maybeTMonad.flatMap(user ->
MAYBE_T.widen(MaybeT.fromKind(fetchPreferencesAsync(user.id()))),
userMT);
}
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 (e.g. CompletableFuture). 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.
Monad<CompletableFutureKind.Witness> futureMonad = CompletableFutureMonad.INSTANCE;
MonadError<MaybeTKind.Witness<CompletableFutureKind.Witness>, Unit> maybeTMonad =
new MaybeTMonad<>(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 between MaybeT<F, A> and its Kind representation.
Kind<MaybeTKind.Witness<F>, A> kind = MAYBE_T.widen(maybeT);
MaybeT<F, A> concrete = MAYBE_T.narrow(kind);
Key Operations
maybeTMonad.of(value): Lifts a nullable valueAintoMaybeT. Result:F<Maybe.fromNullable(value)>.maybeTMonad.map(f, maybeTKind): AppliesA -> Bto theJustvalue. Iffreturnsnull, it propagatesF<Nothing>. Result:F<Maybe<B>>.maybeTMonad.flatMap(f, maybeTKind): Sequences operations. IfJust(a), appliesf(a)to get the nextMaybeT. IfNothing, short-circuits toF<Nothing>.maybeTMonad.raiseError(Unit.INSTANCE): CreatesMaybeTrepresentingF<Nothing>.maybeTMonad.handleErrorWith(maybeTKind, handler): Recovers fromNothing. The handlerUnit -> Kind<MaybeTKind.Witness<F>, A>is invoked withUnit.INSTANCE.
Creating MaybeT Instances
Monad<OptionalKind.Witness> optMonad = OptionalMonad.INSTANCE;
// 1. From a non-null value: F<Just(value)>
MaybeT<OptionalKind.Witness, String> mtJust = MaybeT.just(optMonad, "Hello");
// 2. Nothing state: F<Nothing>
MaybeT<OptionalKind.Witness, String> mtNothing = MaybeT.nothing(optMonad);
// 3. From a plain Maybe: F<Maybe(input)>
MaybeT<OptionalKind.Witness, Integer> 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"));
MaybeT<OptionalKind.Witness, String> 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")));
MaybeT<OptionalKind.Witness, String> mtFromKind = MaybeT.fromKind(nestedKind);
// Accessing the wrapped value:
Kind<OptionalKind.Witness, Maybe<String>> wrappedValue = mtJust.value();
Optional<Maybe<String>> unwrappedOptional = OPTIONAL.narrow(wrappedValue);
// → Optional.of(Maybe.just("Hello"))
Real-World Example: Async Resource Fetching
The problem: You need to fetch a user asynchronously, and if found, fetch their preferences. Each step might return Nothing. You want clean composition without manual Maybe.fold at every step.
The solution:
Monad<CompletableFutureKind.Witness> futureMonad = CompletableFutureMonad.INSTANCE;
MonadError<MaybeTKind.Witness<CompletableFutureKind.Witness>, Unit> maybeTMonad =
new MaybeTMonad<>(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
Kind<CompletableFutureKind.Witness, Maybe<UserPreferences>>
getUserPreferencesWorkflow(String userId) {
var userMT = MAYBE_T.widen(MaybeT.fromKind(fetchUserAsync(userId)));
var preferencesMT = maybeTMonad.flatMap(
user -> {
System.out.println("User found: " + user.name());
return MAYBE_T.widen(MaybeT.fromKind(fetchPreferencesAsync(user.id())));
},
userMT);
// Unwrap to get Future<Maybe<UserPreferences>>
return MAYBE_T.narrow(preferencesMT).value();
}
Why this works: The flatMap lambda only executes if the user was found (Just). If fetchUserAsync returns Nothing, the entire chain short-circuits to Future<Nothing>.
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 MaybeT when 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.
- 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)