Option<T>

Runnable example: OptionSample.java

What is Option<T>?

Option<T> models a value that may or may not be present. It is a sealed interface with exactly two implementations:

  • Some<T> — holds a non-null value.
  • None<T> — represents absence, with no value.

Unlike a raw null, an Option is explicit in the type system: callers are forced to handle both the present and absent cases. Unlike java.util.Optional, Option is a sealed interface and can be used in switch expressions with exhaustive pattern matching. The decision to provide a custom Option<T> type instead of reusing java.util.Optional is documented in ADR-015 — Option<T> as a custom type instead of java.util.Optional.

Creating instances

Option.some(value)

Wraps a known, non-null value. Throws NullPointerException if the argument is null.

Option<String> name = Option.some("Alice");
// name is Some("Alice")

Option.none()

Returns the singleton absent value.

Option<String> missing = Option.none();
// missing is None

Option.ofNullable(value)

Creates an Option from a value that may be null. Returns None when the argument is null, Some(value) otherwise. This is the most common factory when bridging from legacy code.

String raw = fetchFromLegacyApi(); // may return null
Option<String> result = Option.ofNullable(raw);

Option.fromOptional(optional)

Converts a java.util.Optional into an Option.

Optional<User> jdkOptional = repo.findById(id);
Option<User> dmxOption = Option.fromOptional(jdkOptional);

Converting from Result or Try

Result and Try expose toOption() to discard the error/exception track. The static factory forms are symmetric: Option.fromResult(result) and Option.fromTry(t). Option.fromTryOptional(t) flattens a Try<Optional<T>> in one step.

// Result -> Option: discard the error track
Option<User> fromResult1 = userResult.toOption(); // instance method
Option<User> fromResult2 = Option.fromResult(userResult); // static factory (same semantics)
// Try -> Option: discard the exception track
// Note: Try.Success(null) produced by Try.run() maps to None
Option<Config> fromTry1 = tryReadConfig.toOption(); // instance method
Option<Config> fromTry2 = Option.fromTry(tryReadConfig); // static factory (same semantics)
// Try<Optional<T>> -> Option<T>: flatten both layers in one step
// Success(Optional.of(v)) -> Some(v)
// Success(Optional.empty()) -> None
// Failure(ex) -> None (exception discarded)
Option<Item> item = Option.fromTryOptional(
Try.of(() -> repository.findById(id)));

Checking state

Prefer structural operations (fold, map, pattern matching) over imperative state checks, but the predicates are available:

option.isDefined() // true if Some
option.isEmpty() // true if None

You can also use Java pattern matching exhaustively:

switch (option) {
case Option.Some<String>(var v) -> System.out.println("got: " + v);
case Option.None<String> _ -> System.out.println("nothing here");
}

Extracting values

MethodWhen NoneNotes
get()Throws NoSuchElementExceptionOnly safe after an isDefined() check; prefer alternatives.
getOrElse(fallback)Returns fallbackFallback is always evaluated — use getOrElseGet for expensive defaults.
getOrElseGet(supplier)Calls supplierLazily computed fallback.
getOrNull()Returns nullBridge to null-expecting APIs. Avoid propagating the null further.
getOrThrow(supplier)Throws supplied exceptionUse when absence is a programming error at that call site.
fold(onNone, onSome)Calls onNoneThe most expressive extractor: forces you to handle both branches.
// Preferred: fold makes both branches explicit
String display = option.fold(
() -> "anonymous",
name -> "Hello, " + name
);
// Lazy fallback — DB call only when None
User user = maybeUser.getOrElseGet(() -> userRepo.defaultUser());
// Throw a domain exception when absence is a bug
Config cfg = maybeConfig.getOrThrow(
() -> new IllegalStateException("Config not loaded"));

Transforming values

map(mapper)

Applies a function to the value if present. Returns None unchanged. The mapper must not be null and must not return null — both throw NullPointerException eagerly, consistent with the @NullMarked contract and the behavior of flatMap.

Option<String> upper = Option.some("hello").map(String::toUpperCase);
// Some("HELLO")
Option<String> none = Option.<String>none().map(String::toUpperCase);
// None

flatMap(mapper)

Like map, but the mapper itself returns an Option. Use this to chain operations that may also produce absence.

Option<Address> address = findUser(id) // Option<User>
.flatMap(user -> user.getPrimaryAddress()); // returns Option<Address>

filter(predicate)

Keeps the value only if it satisfies the predicate; otherwise returns None.

Option<Integer> positive = Option.some(-5).filter(n -> n > 0);
// None — value fails the predicate

peek(action)

Runs a side-effecting action on the value (e.g., logging) without altering the Option. No-op on None. Returns this for chaining.

Option<Order> order = findOrder(id)
.peek(o -> log.debug("Processing order {}", o.id()))
.filter(Order::isActive);

match(onNone, onSome)

Terminal side-effecting variant of fold. Both branches return void.

option.match(
() -> metrics.increment("cache.miss"),
value -> metrics.increment("cache.hit")
);

Composing Options

Fallback chains with orElse

Return an alternative Option when this one is None. The supplier overload is lazy — use it when computing the alternative is expensive.

// Eager alternative
Option<String> result = primary.orElse(secondary);
// Lazy alternative (computed only when primary is None)
Option<String> result = primary.orElse(() -> computeSecondary());

Zipping two or more Options

Combine multiple independent Option values. Returns None if any input is None.

// Zip to a Tuple2
Option<Tuple2<String, Integer>> pair =
Option.zip(nameOption, ageOption);
// Zip with a custom combiner
Option<Greeting> greeting =
Option.zip(nameOption, localeOption, Greeting::new);

Pairing a value with a derived Option — zipWith / flatZip

zipWith(mapper) applies a function to the current value and pairs the original value with whatever the function returns. If the mapper returns None, the whole result is None. Unlike zip(Option), the second Option is computed lazily from the first value.

flatZip is an alias that may read more naturally at the call site.

// zipWith(Function) — pair the current value with a derived Option
Option<String> name = Option.some("alice");
Option<Tuple2<String, Integer>> result =
name.zipWith(n -> lookupAge(n));
// Some(Tuple2("alice", 30)) — if lookupAge returns Some(30)
// None — if lookupAge returns None or name is None
// flatZip is an alias with the same semantics
Option<Tuple2<String, Integer>> same =
name.flatZip(n -> lookupAge(n));
// Contrast with zip(Option) — the second Option is pre-evaluated
Option<Integer> age = Option.some(30);
Option<Tuple2<String, Integer>> fromZip = name.zip(age);
// Both are None when name is None, but zip always evaluates age regardless

Collecting a list with sequence

Converts a List<Option<T>> into an Option<List<T>>. Returns None as soon as any element is None. The stream-based overload uses Stream.gather(Gatherer.ofSequential(...)) for true short-circuit (see ADR-010).

List<Option<User>> lookups = ids.stream()
.map(userRepo::findById)
.toList();
Option<List<User>> allOrNone = Option.sequence(lookups);

Collecting a stream with sequenceCollector

Option.sequenceCollector() is the stream Collector counterpart to sequence. It reduces a Stream<Option<V>> to a single Option<List<V>>Some(list) only if every element is Some, None if any element is None.

// All Some → Option containing all values
Option<List<String>> result = Stream.of(
Option.some("alice"), Option.some("bob"))
.collect(Option.sequenceCollector());
// Some(["alice", "bob"])
// Any None → None
Option<List<String>> missing = Stream.of(
Option.some("alice"), Option.<String>none())
.collect(Option.sequenceCollector());
// None
// Empty stream → Some([])
Option<List<String>> empty = Stream.<Option<String>>empty()
.collect(Option.sequenceCollector());
// Some([])
// Typical use: look up all IDs, fail if any is missing
Option<List<User>> allFound = ids.stream()
.map(userRepo::findById) // Stream<Option<User>>
.collect(Option.sequenceCollector()); // Option<List<User>>

Collector facade: Options

Options is a single discoverable entry point for all Stream<Option<T>> collectors — analogous to java.util.stream.Collectors. Import dmx.fun.Options instead of remembering which collector lives on which type. The decision to provide companion facade classes as pure-delegation entry points is documented in ADR-014 — Facade collectors (Results, Options, Tries) as a single entry point.

MethodReturnsSemantics
Options.presentToList()List<T>Drops None — returns only present values
Options.sequence()Option<List<T>>All-or-nothing — None if any element is None
Options.toNonEmptyList()Option<NonEmptyList<T>>Wraps stream into NonEmptyList, or None if empty
// ── Options.presentToList() — keep only present values ───────────────────────
List<String> names = Stream.<Option<String>>of(
Option.some("alice"), Option.none(), Option.some("bob"), Option.none())
.collect(Options.presentToList());
// ["alice", "bob"]
// Real-world: resolve a batch of user IDs; ignore the ones not found
List<User> found = ids.stream()
.map(userRepo::findById) // Stream<Option<User>>
.collect(Options.presentToList()); // List<User> — absent entries dropped
// ── Options.sequence() — all-or-nothing: None if any is None ─────────────────
Option<List<User>> allOrNone = ids.stream()
.map(userRepo::findById)
.collect(Options.sequence());
// Some([...]) only when every ID resolves; None otherwise
// ── Options.toNonEmptyList() — wrap a stream into a NonEmptyList if non-empty ─
Option<NonEmptyList<String>> nel = items.stream()
.filter(item -> item.isActive())
.collect(Options.toNonEmptyList());
nel.match(
() -> log.warn("no active items"),
items -> process(items) // items is a guaranteed NonEmptyList
);

Stream integration with stream()

Turns an Option into a one-element or empty stream. Useful for integrating into Stream pipelines without filter + map.

long count = options.stream()
.flatMap(Option::stream) // flatten Nones out
.count();

Interoperability

ConversionMethodNotes
OptionOptionaloption.toOptional()Some(v)Optional.of(v), NoneOptional.empty()
OptionResultoption.toResult(errorIfNone)None becomes Err(errorIfNone)
OptionTryoption.toTry(exceptionSupplier)None becomes Failure; supplier called lazily
OptionEitheroption.toEither(leftIfNone)Some(v)Right(v), NoneLeft(leftIfNone)
OptionCompletableFutureoption.toFuture()Some(v) → completed future, None → failed with NoSuchElementException
OptionalOptionOption.fromOptional(optional)
ResultOptionOption.fromResult(result) or result.toOption()Err is discarded
TryOptionOption.fromTry(t) or t.toOption()Failure is discarded; Success(null)None
Try<Optional<T>>OptionOption.fromTryOptional(t)Flattens both layers; Failure and Optional.empty()None
EitherOptionOption.fromEither(either) or either.toOption()Left is discarded; Right(v)Some(v)
Option<User> user = findUser(id);
// ── Option → other types ────────────────────────────────────────────────────
// → Optional (bridge to JDK APIs)
Optional<User> optional = user.toOptional();
// → Result (None becomes Err with the supplied error value)
Result<User, String> result = user.toResult("User not found: " + id);
// → Try (None becomes Failure; supplier is called lazily)
Try<User> tried = user.toTry(() -> new NoSuchElementException("User " + id + " not found"));
// → Either (Some → Right, None → Left with the supplied left value)
Either<String, User> either = user.toEither("not-found");
// → CompletableFuture (Some → completed, None → failed with NoSuchElementException)
CompletableFuture<User> future = user.toFuture();
// ── Other types → Option ────────────────────────────────────────────────────
// Optional → Option
Option<User> fromOptional = Option.fromOptional(legacyOptional);
// Result → Option (Err is discarded)
Option<User> fromResult = Option.fromResult(userResult);
// Try → Option (Failure is discarded; Try.Success(null) from Try.run() → None)
Option<Config> fromTry = Option.fromTry(tryReadConfig);
// Try<Optional<T>> → Option<T> (flattens both layers in one step)
Option<Item> fromTryOpt = Option.fromTryOptional(
Try.of(() -> repository.findById(id)));
// Either → Option (Left is discarded; Right(v) → Some(v))
Option<User> fromEither = Option.fromEither(eitherValue);

See the Combining Types page for the full conversion matrix and composition patterns.

Option vs null vs Optional

nullOptional<T>Option<T>
Null-safetyNone — NPE at runtimeGood — explicitGood — explicit
Exhaustive handlingNoNo (runtime only)Yes — sealed, switch patterns
Use in records / genericsYes (fragile)Discouraged (not serializable)Yes — record-based, serializable
Compose with Result / Try / EitherNoNoYes — first-class conversions
Holds null valuesYesNoNo

Common pitfalls

Nested Options

Option<Option<T>> almost always indicates a mistake. Use flatMap instead of map when the mapper itself returns an Option.

// Bad: creates Option<Option<Address>>
Option<Option<Address>> nested = user.map(User::getAddress);
// Good: flat result
Option<Address> address = user.flatMap(User::getAddress);

Mapper returning null

Under @NullMarked, map and flatMap both throw NullPointerException when the mapper returns null. Use Option.ofNullable(mapper.apply(value)) explicitly if the mapper may return null and you want None instead of an exception.

// map and flatMap both throw NPE when the mapper returns null (@NullMarked contract)
Option<String> bad = option.map(User::getName); // NPE if getName() returns null
// If the mapped method may return null, convert explicitly:
Option<String> safe = Option.ofNullable(option.getOrNull())
.flatMap(u -> Option.ofNullable(u.getName())); // None when getName() is null

Calling get() unconditionally

get() throws NoSuchElementException on None. Use getOrElse, fold, or pattern matching instead.

Option as a method parameter

Prefer overloaded methods or a nullable parameter over an Option-typed parameter. Option is designed for return types.

Real-world example

A typical service layer pattern: look up a user, find their primary address, format it, and fall back to a default — all without a single null check or try/catch.

public String resolveShippingLabel(long userId) {
return userRepo.findById(userId) // Option<User>
.flatMap(User::primaryAddress) // Option<Address>
.filter(Address::isVerified) // discard unverified
.map(AddressFormatter::format) // Option<String>
.getOrElse("Default Warehouse, 1 Main St");
}
// JPA / Spring Data repositories return Optional<T>.
// Convert directly so DataAccessExceptions propagate as expected (5xx).
public Option<Item> findById(Long id) {
return Option.fromOptional(itemRepository.findById(id));
}

Each step propagates None silently. No intermediate nulls, no nested conditionals.