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.

What You'll Learn

  • 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 For comprehensions to keep witness types localised
  • Using just, nothing, fromMaybe, liftF, and fromKind to construct MaybeT values
  • When to use the MaybePath Path type instead of raw MaybeT

See Example Code

Path First, Stack Later

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 inner Maybe.
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);

Working with Kind

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

OperationBehaviour
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

Asynchronous Optional 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())));
});

mapT vs map

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:

AspectMaybeTOptionalT
Inner typeMaybe<A>java.util.Optional<A>
Best forHigher-Kinded-J ecosystem codeIntegrating with existing Java code
FP-nativeYes (designed for composition)Wraps Java's standard library
SerialisationNo warningsIdentity-sensitive operation warnings
Team familiarityRequires learning MaybeUses 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 Optional and 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.


Common Mistakes

  • Confusing Maybe.nothing() with null: MaybeT.of(null) will use Maybe.fromNullable(null), which produces Nothing. Be explicit about intent; use MaybeT.nothing(monad) when you mean absence.
  • Using MaybeT when you need error information: Nothing carries no reason for the absence. If you need to know why a value is missing, use EitherT with a descriptive error type instead.
  • Reaching for the transformer when MaybePath would do: if your outer monad is one Path already wraps, MaybePath is shorter, has less ceremony, and reads more naturally.

See Also


Further Reading

Hands-On Learning

The MaybeT exercise lives alongside the OptionalT exercises in Tutorial 02: Async with Absence (5 exercises).


Previous: OptionalT Next: ReaderT