The OptionalT Transformer:
When Absence Meets Other Effects
- How to integrate Java's Optional with other monadic contexts
- Building async workflows where each step might return empty results
- Using
some,none, andfromOptionalto construct OptionalT values - Creating multi-step data retrieval with graceful failure handling
- Providing default values when optional chains result in empty
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 absent
│
handleErrorWith 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 │
└──────────────────────────────────────────────────────────┘
F: The witness type of the outer monad (e.g.,CompletableFutureKind.Witness).A: The type of the value that might be present within theOptional.
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);
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
optionalTMonad.of(value): Lifts a nullable valueAintoOptionalT. Result:F<Optional.ofNullable(value)>.optionalTMonad.map(func, optionalTKind): AppliesA -> Bto the present value. Iffuncreturnsnull, the result becomesF<Optional.empty()>. Result:OptionalT(F<Optional<B>>).optionalTMonad.flatMap(func, optionalTKind): Sequences operations. If present, appliesfuncto get the nextOptionalT. If empty at any point, short-circuits toF<Optional.empty()>.optionalTMonad.raiseError(Unit.INSTANCE): Creates anOptionalTrepresenting absence. Result:F<Optional.empty()>.optionalTMonad.handleErrorWith(optionalTKind, handler): Handles an empty state. The handlerUnit -> Kind<OptionalTKind.Witness<F>, A>is invoked when the innerOptionalis empty.
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
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
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.
- Null vs. empty confusion:
OptionalT.liftFtreats anullvalue insideF<A>asOptional.empty(). If you want to explicitly signal absence, useOptionalT.nonerather than relying on null propagation. - Unit as error type: When using
handleErrorWith, the handler function receivesUnit.INSTANCE, not a descriptive error. If you need typed error information, considerEitherTinstead.
- 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
- Java Optional Best Practices - Comprehensive Baeldung guide (20 min read)
- Null References: The Billion Dollar Mistake - Tony Hoare's historic talk on why Optional matters (60 min watch)