The OptionalT Transformer:

When Absence Meets Other Effects

What You'll Learn

  • How to integrate Java's Optional with other monadic contexts
  • Building async workflows where each step might return empty results
  • Using some, none, and fromOptional to construct OptionalT values
  • Creating multi-step data retrieval with graceful failure handling
  • Providing default values when optional chains result in empty

See Example Code:


The Problem: Nested Async Lookups

Consider fetching a user, then their profile, then their preferences. Each step is async and might return empty:

// Without OptionalT: manual nesting
CompletableFuture<Optional<UserPreferences>> getPreferences(String userId) {
    return fetchUserAsync(userId).thenCompose(optUser ->
        optUser.map(user ->
            fetchProfileAsync(user.id()).thenCompose(optProfile ->
                optProfile.map(profile ->
                    fetchPrefsAsync(profile.userId())
                ).orElse(CompletableFuture.completedFuture(Optional.empty()))
            )
        ).orElse(CompletableFuture.completedFuture(Optional.empty()))
    );
}

Each step requires checking the Optional, providing a fallback CompletableFuture.completedFuture(Optional.empty()) for the absent case, and nesting deeper. Three lookups; three layers of map/orElse. The fallback expression is identical every time.

The Solution: OptionalT

// With OptionalT: flat composition
OptionalT<CompletableFutureKind.Witness, UserPreferences> getPreferences(String userId) {
    var userOT = OPTIONAL_T.widen(OptionalT.fromKind(fetchUserAsync(userId)));
    var workflow = For.from(optionalTMonad, userOT)
        .from(user -> OPTIONAL_T.widen(OptionalT.fromKind(fetchProfileAsync(user.id()))))
        .from(profile -> OPTIONAL_T.widen(OptionalT.fromKind(fetchPrefsAsync(profile.userId()))))
        .yield((user, profile, prefs) -> prefs);
    return OPTIONAL_T.narrow(workflow);
}

If any step returns empty, subsequent steps are skipped. No manual orElse fallbacks, no repeated Optional.empty() wrapping.

The Railway View

    Present  ═══●═══════════●═══════════●═══════════▶  UserPreferences
              fetchUser   fetchProfile fetchPrefs
              (flatMap)   (flatMap)    (flatMap)
                  ╲           ╲            ╲
                   ╲           ╲            ╲  empty: skip remaining steps
                    ╲           ╲            ╲
    Empty    ────●──────────●─────────────●──────────▶  Optional.empty()
            user absent  profile absent  prefs absenthandleErrorWith    provide defaults
                                          │
                                          ●═══▶  default UserPreferences

Each flatMap runs inside the outer monad F (e.g. CompletableFuture). If the inner Optional is empty, subsequent steps are skipped. handleErrorWith can provide a fallback value when the chain yields nothing.


How OptionalT Works

OptionalT<F, A> wraps a computation yielding Kind<F, Optional<A>>. It represents an effectful computation in F that may or may not produce a value.

    ┌──────────────────────────────────────────────────────────┐
    │  OptionalT<CompletableFutureKind.Witness, Value>         │
    │                                                          │
    │  ┌─── CompletableFuture ──────────────────────────────┐  │
    │  │                                                    │  │
    │  │  ┌─── Optional ────────────────────────────────┐   │  │
    │  │  │                                             │   │  │
    │  │  │   empty()of(value)          │   │  │
    │  │  │                      │                      │   │  │
    │  │  └─────────────────────────────────────────────┘   │  │
    │  │                                                    │  │
    │  └────────────────────────────────────────────────────┘  │
    │                                                          │
    │  flatMap ──▶ sequences F, then routes on Optional       │
    │  map ──────▶ transforms present value only              │
    │  raiseError(Unit) ──▶ creates empty() in F              │
    │  handleErrorWith ──▶ recovers from empty                │
    └──────────────────────────────────────────────────────────┘

optional_t_transformer.svg

  • F: The witness type of the outer monad (e.g., CompletableFutureKind.Witness).
  • A: The type of the value that might be present within the Optional.
public record OptionalT<F, A>(@NonNull Kind<F, Optional<A>> value)
    implements OptionalTKind<F, A> {
  // ... static factory methods ...
}

Setting Up OptionalTMonad

The OptionalTMonad<F> class implements MonadError<OptionalTKind.Witness<F>, Unit>. The error type is Unit, signifying that an "error" is the Optional.empty() state (absence carries no information beyond its occurrence).

Monad<CompletableFutureKind.Witness> futureMonad = CompletableFutureMonad.INSTANCE;

OptionalTMonad<CompletableFutureKind.Witness> optionalTFutureMonad =
    new OptionalTMonad<>(futureMonad);

Type Witness and Helpers

Witness Type: OptionalTKind<F, A> extends Kind<OptionalTKind.Witness<F>, A>. The outer monad F is fixed; A is the variable value type.

KindHelper: OptionalTKindHelper provides OPTIONAL_T.widen and OPTIONAL_T.narrow for safe conversion between OptionalT<F, A> and its Kind representation.

Kind<OptionalTKind.Witness<F>, A> kind = OPTIONAL_T.widen(optionalT);
OptionalT<F, A> concrete = OPTIONAL_T.narrow(kind);

Key Operations

Key Operations with OptionalTMonad:

  • optionalTMonad.of(value): Lifts a nullable value A into OptionalT. Result: F<Optional.ofNullable(value)>.
  • optionalTMonad.map(func, optionalTKind): Applies A -> B to the present value. If func returns null, the result becomes F<Optional.empty()>. Result: OptionalT(F<Optional<B>>).
  • optionalTMonad.flatMap(func, optionalTKind): Sequences operations. If present, applies func to get the next OptionalT. If empty at any point, short-circuits to F<Optional.empty()>.
  • optionalTMonad.raiseError(Unit.INSTANCE): Creates an OptionalT representing absence. Result: F<Optional.empty()>.
  • optionalTMonad.handleErrorWith(optionalTKind, handler): Handles an empty state. The handler Unit -> Kind<OptionalTKind.Witness<F>, A> is invoked when the inner Optional is empty.

Transforming the Outer Monad with mapT

Sometimes you need to change the outer monad of an OptionalT without touching the inner Optional. Perhaps you have awaited an async result and want to continue in a synchronous context, or you want to apply a natural transformation to switch effect types.

mapT applies a function to the wrapped Kind<F, Optional<A>> and produces a new OptionalT<G, A>:

  OptionalT< F , A >  ── mapT(f) ──>  OptionalT< G , A >
       │                                     │
  ┌────┴────┐                           ┌────┴────┐
  │    F    │   f: F[...] -> G[...]     │    G    │
  │ ┌─────┐ │         ====>             │ ┌─────┐ │
  │ │Opt  │ │  inner Optional sealed    │ │Opt  │ │
  │ │  A  │ │                           │ │  A  │ │
  │ └─────┘ │                           │ └─────┘ │
  └─────────┘                           └─────────┘
// Switch from Future to Optional after awaiting an async result
OptionalT<CompletableFutureKind.Witness, String> asyncOt = ...;

OptionalT<OptionalKind.Witness, String> syncOt =
    asyncOt.mapT(futureKind -> {
      Optional<String> awaited = FUTURE.narrow(futureKind).join();
      return OPTIONAL.widen(Optional.of(awaited));
    });

mapT vs map

map transforms the value inside the Optional (the A in Optional.of(A)). mapT transforms the outer monad wrapping the Optional — the F in F<Optional<A>>. They operate at different levels of the transformer stack.


Creating OptionalT Instances

Creating OptionalT Instances

Monad<CompletableFutureKind.Witness> futureMonad = CompletableFutureMonad.INSTANCE;

// 1. From an existing F<Optional<A>>
Kind<CompletableFutureKind.Witness, Optional<String>> fOptional =
    FUTURE.widen(CompletableFuture.completedFuture(Optional.of("Data")));
OptionalT<CompletableFutureKind.Witness, String> ot1 = OptionalT.fromKind(fOptional);

// 2. From a present value: F<Optional.of(a)>
OptionalT<CompletableFutureKind.Witness, String> ot2 =
    OptionalT.some(futureMonad, "Data");

// 3. Empty: F<Optional.empty()>
OptionalT<CompletableFutureKind.Witness, String> ot3 = OptionalT.none(futureMonad);

// 4. From a plain java.util.Optional: F<Optional<A>>
OptionalT<CompletableFutureKind.Witness, Integer> ot4 =
    OptionalT.fromOptional(futureMonad, Optional.of(123));

// 5. Lifting F<A> into OptionalT (null → empty, non-null → present)
Kind<CompletableFutureKind.Witness, String> fValue =
    FUTURE.widen(CompletableFuture.completedFuture("Data"));
OptionalT<CompletableFutureKind.Witness, String> ot5 =
    OptionalT.liftF(futureMonad, fValue);

// Accessing the wrapped value:
Kind<CompletableFutureKind.Witness, Optional<String>> wrappedFVO = ot1.value();
CompletableFuture<Optional<String>> futureOptional = FUTURE.narrow(wrappedFVO);

Real-World Example: Async Multi-Step Data Retrieval

Asynchronous Multi-Step Data Retrieval

The problem: You need to fetch a user, then their profile, then their preferences. Each step is async and might not find data. You want the chain to short-circuit on the first empty result.

The solution:

static final Monad<CompletableFutureKind.Witness> futureMonad =
    CompletableFutureMonad.INSTANCE;
static final OptionalTMonad<CompletableFutureKind.Witness> optionalTFutureMonad =
    new OptionalTMonad<>(futureMonad);

// Service stubs return Future<Optional<T>>
static Kind<CompletableFutureKind.Witness, Optional<User>> fetchUserAsync(String userId) {
    return FUTURE.widen(CompletableFuture.supplyAsync(() ->
        "user1".equals(userId) ? Optional.of(new User(userId, "Alice"))
                               : Optional.empty()));
}

// Workflow: user → profile → preferences
static OptionalT<CompletableFutureKind.Witness, UserPreferences>
    getFullUserPreferences(String userId) {

  OptionalT<CompletableFutureKind.Witness, User> userOT =
      OptionalT.fromKind(fetchUserAsync(userId));

  OptionalT<CompletableFutureKind.Witness, UserProfile> profileOT =
      OPTIONAL_T.narrow(optionalTFutureMonad.flatMap(
          user -> OPTIONAL_T.widen(
              OptionalT.fromKind(fetchProfileAsync(user.id()))),
          OPTIONAL_T.widen(userOT)));

  return OPTIONAL_T.narrow(optionalTFutureMonad.flatMap(
      profile -> OPTIONAL_T.widen(
          OptionalT.fromKind(fetchPrefsAsync(profile.userId()))),
      OPTIONAL_T.widen(profileOT)));
}

Why this works: Each flatMap only executes its lambda if the previous step produced a present value. If fetchUserAsync returns empty, neither fetchProfileAsync nor fetchPrefsAsync are called.


Providing Defaults with Error Recovery

Recovery with Default Values

The problem: When the preference chain returns empty, you want to provide default preferences rather than propagating the absence.

The solution:

static OptionalT<CompletableFutureKind.Witness, UserPreferences>
    getPrefsWithDefault(String userId) {

  OptionalT<CompletableFutureKind.Witness, UserPreferences> prefsAttempt =
      getFullUserPreferences(userId);

  Kind<OptionalTKind.Witness<CompletableFutureKind.Witness>, UserPreferences>
      recovered = optionalTFutureMonad.handleErrorWith(
          OPTIONAL_T.widen(prefsAttempt),
          (Unit v) -> {
              UserPreferences defaultPrefs =
                  new UserPreferences(userId, "default-light");
              return OPTIONAL_T.widen(OptionalT.some(futureMonad, defaultPrefs));
          });

  return OPTIONAL_T.narrow(recovered);
}

The handleErrorWith handler receives Unit (since absence carries no information) and returns an OptionalT containing the default preferences.


Common Mistakes

  • Null vs. empty confusion: OptionalT.liftF treats a null value inside F<A> as Optional.empty(). If you want to explicitly signal absence, use OptionalT.none rather than relying on null propagation.
  • Unit as error type: When using handleErrorWith, the handler function receives Unit.INSTANCE, not a descriptive error. If you need typed error information, consider EitherT instead.

See Also

  • Monad Transformers - General concept and choosing the right transformer
  • MaybeT - Equivalent functionality for Higher-Kinded-J's Maybe type
  • EitherT - When you need typed errors, not just absence

Further Reading

Previous: EitherT Next: MaybeT