Optional Contexts: Graceful Absence in Effects
"Sum tyms theres mor in the emty than the ful."
— Russell Hoban, Riddley Walker
Hoban's riddler knew that absence can be as meaningful as presence. A database query that returns nothing isn't always an error—sometimes the record genuinely doesn't exist, and that's valuable information. OptionalContext and JavaOptionalContext model this graceful absence within effectful computations, giving you the compositional power of transformers without forcing non-existence into an error mould.
- The difference between
OptionalContext(Maybe) andJavaOptionalContext(java.util.Optional) - Creating optional contexts from nullable suppliers and existing values
- Chaining computations that may return nothing
- Providing fallbacks with
orElse()andorElseValue() - Converting to
ErrorContextwhen absence becomes an error
Two Flavours of Optionality
Higher-Kinded-J provides two optional context types:
| Context | Wraps | Uses |
|---|---|---|
OptionalContext<F, A> | MaybeT<F, A> | Library's Maybe<A> type |
JavaOptionalContext<F, A> | OptionalT<F, A> | Java's java.util.Optional<A> |
They're functionally equivalent. Choose based on what your codebase already uses:
- If you're working with code that uses
Optional, useJavaOptionalContext - If you're using Higher-Kinded-J's
Maybethroughout, useOptionalContext - If starting fresh, either works—
Maybeis slightly more idiomatic for FP patterns
The Problem
Consider a lookup chain:
// Each might return null or Optional.empty()
User user = cache.get(userId);
if (user == null) {
user = database.find(userId);
}
if (user == null) {
user = legacySystem.lookup(userId);
}
if (user == null) {
throw new UserNotFoundException(userId);
}
Four null checks. Three nested lookups. The actual logic—try cache, then database, then legacy—is obscured by defensive programming.
The Solution
With OptionalContext:
OptionalContext<IOKind.Witness, User> user =
OptionalContext.<User>io(() -> cache.get(userId))
.orElse(() -> OptionalContext.io(() -> database.find(userId)))
.orElse(() -> OptionalContext.io(() -> legacySystem.lookup(userId)));
// Convert to ErrorContext when absence is actually an error
ErrorContext<IOKind.Witness, UserError, User> required =
user.toErrorContext(new UserError("User not found: " + userId));
The lookup chain reads top-to-bottom. Fallbacks are explicit. The point where absence becomes an error is clear.
Creating OptionalContexts
From Nullable Suppliers
The io() factory handles null gracefully—null becomes empty:
// If findById returns null, the context is empty
OptionalContext<IOKind.Witness, User> user =
OptionalContext.io(() -> repository.findById(userId));
// Same pattern with JavaOptionalContext
JavaOptionalContext<IOKind.Witness, User> user =
JavaOptionalContext.io(() -> repository.findById(userId));
From Maybe/Optional-Returning Computations
When your code already returns the optional type:
// Maybe-returning supplier
OptionalContext<IOKind.Witness, Config> config =
OptionalContext.ioMaybe(() -> configLoader.load("app.properties"));
// Optional-returning supplier
JavaOptionalContext<IOKind.Witness, Config> config =
JavaOptionalContext.ioOptional(() -> configLoader.load("app.properties"));
Pure Values
For known values:
// Present value
OptionalContext<IOKind.Witness, Integer> some = OptionalContext.some(42);
JavaOptionalContext<IOKind.Witness, Integer> present = JavaOptionalContext.some(42);
// Empty
OptionalContext<IOKind.Witness, Integer> none = OptionalContext.none();
JavaOptionalContext<IOKind.Witness, Integer> absent = JavaOptionalContext.none();
// From existing Maybe/Optional
Maybe<User> maybe = Maybe.just(user);
OptionalContext<IOKind.Witness, User> fromMaybe = OptionalContext.fromMaybe(maybe);
Optional<User> optional = Optional.of(user);
JavaOptionalContext<IOKind.Witness, User> fromOpt = JavaOptionalContext.fromOptional(optional);
Transforming Values
map: Transform Present Values
OptionalContext<IOKind.Witness, String> name = OptionalContext.some("Alice");
OptionalContext<IOKind.Witness, String> upper = name.map(String::toUpperCase);
// → some("ALICE")
OptionalContext<IOKind.Witness, String> empty = OptionalContext.none();
OptionalContext<IOKind.Witness, String> stillEmpty = empty.map(String::toUpperCase);
// → none (map doesn't run on empty)
Chaining Computations
via: Chain Dependent Lookups
OptionalContext<IOKind.Witness, Address> address =
OptionalContext.<User>io(() -> userRepo.findById(userId))
.via(user -> OptionalContext.io(() -> addressRepo.findByUserId(user.id())));
If the user isn't found, the address lookup never runs. If the user exists but has no address, the result is empty. Both cases produce the same none() outcome.
flatMap: Type-Preserving Chain
OptionalContext<IOKind.Witness, Profile> profile =
lookupUser(userId)
.flatMap(user -> lookupProfile(user.profileId()))
.flatMap(profile -> enrichProfile(profile));
then: Sequence Ignoring Values
OptionalContext<IOKind.Witness, String> result =
validateExists()
.then(() -> fetchData())
.then(() -> processResult());
Providing Fallbacks
orElse: Fallback to Another Context
The primary pattern for lookup chains:
OptionalContext<IOKind.Witness, Config> config =
OptionalContext.<Config>io(() -> loadFromEnvironment())
.orElse(() -> OptionalContext.io(() -> loadFromFile()))
.orElse(() -> OptionalContext.io(() -> loadFromDefaults()))
.orElse(() -> OptionalContext.some(Config.hardcodedDefaults()));
Each fallback runs only if all previous attempts returned empty.
orElseValue: Fallback to a Direct Value
When the fallback is known:
OptionalContext<IOKind.Witness, Integer> count =
OptionalContext.<Integer>io(() -> cache.getCount())
.orElseValue(0); // Default to zero if not cached
recover: Transform Absence
When you need to compute the fallback:
OptionalContext<IOKind.Witness, Config> config =
loadConfig()
.recover(unit -> {
log.info("No config found, using defaults");
return Config.defaults();
});
The unit parameter is always Unit.INSTANCE—it's the "error" type for optionality, representing the absence of information about why the value is missing.
recoverWith: Fallback to Another Computation
OptionalContext<IOKind.Witness, Data> data =
fetchFromPrimary()
.recoverWith(unit -> fetchFromBackup());
Converting to ErrorContext
Often, absence at some point becomes an error. The boundary is explicit:
OptionalContext<IOKind.Witness, User> optionalUser =
OptionalContext.<User>io(() -> userRepo.findById(userId));
// Absence → Typed Error
ErrorContext<IOKind.Witness, UserNotFound, User> requiredUser =
optionalUser.toErrorContext(new UserNotFound(userId));
// Now we can use ErrorContext operations
Either<UserNotFound, User> result = requiredUser.runIO().unsafeRun();
This runs the underlying computation and converts the result. For deferred conversion, use the escape hatch to the raw transformer.
Converting Between Optional Types
JavaOptionalContext can convert to OptionalContext:
JavaOptionalContext<IOKind.Witness, User> javaOptional =
JavaOptionalContext.io(() -> repo.find(id));
OptionalContext<IOKind.Witness, User> maybeContext =
javaOptional.toOptionalContext();
Execution
runIO: Get an IOPath
// For OptionalContext
OptionalContext<IOKind.Witness, User> optionalCtx = OptionalContext.some(user);
IOPath<Maybe<User>> maybeIO = optionalCtx.runIO();
// For JavaOptionalContext
JavaOptionalContext<IOKind.Witness, User> javaCtx = JavaOptionalContext.some(user);
IOPath<Optional<User>> optionalIO = javaCtx.runIO();
// Execute
Maybe<User> maybeResult = maybeIO.unsafeRun();
Optional<User> optionalResult = optionalIO.unsafeRun();
runIOOrElse: Value or Default
User user = userContext.runIOOrElse(User.guest());
runIOOrThrow: Value or Exception
User user = userContext.runIOOrThrow(); // Throws NoSuchElementException if empty
Real-World Patterns
Cache-Through Pattern
public OptionalContext<IOKind.Witness, Product> getProduct(String id) {
return OptionalContext.<Product>io(() -> cache.get(id))
.orElse(() -> {
Product product = database.find(id);
if (product != null) {
cache.put(id, product); // Populate cache
}
return product == null
? OptionalContext.none()
: OptionalContext.some(product);
});
}
Configuration Layering
public OptionalContext<IOKind.Witness, String> getSetting(String key) {
return OptionalContext.<String>io(() -> System.getenv(key)) // Environment first
.orElse(() -> OptionalContext.io(() -> System.getProperty(key))) // System property
.orElse(() -> OptionalContext.io(() -> configFile.get(key))) // Config file
.orElse(() -> OptionalContext.io(() -> defaults.get(key))); // Defaults last
}
Validation with Optional Fields
record UserInput(String name, String email, String phone) {}
public OptionalContext<IOKind.Witness, String> getContactMethod(UserInput input) {
return OptionalContext.<String>io(() -> nullIfBlank(input.email()))
.orElse(() -> OptionalContext.io(() -> nullIfBlank(input.phone())));
// Returns the first available contact method
}
private String nullIfBlank(String s) {
return s == null || s.isBlank() ? null : s;
}
Graceful Degradation
public OptionalContext<IOKind.Witness, DashboardData> loadDashboard(String userId) {
return OptionalContext.<DashboardData>io(() -> fullDashboardService.load(userId))
.orElse(() -> OptionalContext.io(() -> simplifiedDashboard(userId)))
.orElse(() -> OptionalContext.some(DashboardData.empty()));
}
Escape Hatch
When you need the raw transformer:
OptionalContext<IOKind.Witness, User> ctx = OptionalContext.some(user);
MaybeT<IOKind.Witness, User> transformer = ctx.toMaybeT();
JavaOptionalContext<IOKind.Witness, User> jCtx = JavaOptionalContext.some(user);
OptionalT<IOKind.Witness, User> transformer = jCtx.toOptionalT();
Summary
| Operation | Purpose |
|---|---|
io(supplier) | Create from nullable supplier |
ioMaybe(supplier) / ioOptional(supplier) | Create from Maybe/Optional supplier |
some(value) | Create present context |
none() | Create empty context |
map(f) | Transform present value |
via(f) / flatMap(f) | Chain dependent computation |
orElse(supplier) | Provide fallback context |
orElseValue(value) | Provide fallback value |
toErrorContext(error) | Convert absence to typed error |
runIO() | Extract IOPath for execution |
Optional contexts embrace the wisdom that emptiness can be meaningful. Not every missing value is a bug. Sometimes there's more in the empty than the full.
- MaybeT Transformer - The transformer behind OptionalContext
- OptionalT Transformer - The transformer behind JavaOptionalContext
- Maybe Monad - The Maybe type
- Optional Monad - Working with java.util.Optional
Previous: ErrorContext Next: ConfigContext