Optional<T> is a container that either holds a value or is empty, introduced to make 'a value might be absent' explicit in a method's return type instead of relying on null and hoping callers check. Used as intended — as a return type for methods that may legitimately find nothing — it eliminates a whole class of NullPointerException. Used as a field or parameter, it becomes noise. The distinction is the whole story.
The problem it solves
A method returning null gives the caller no signal that absence is possible; they forget to check and get a NullPointerException at some distant call site. Returning Optional makes absence part of the type, so the compiler and the reader both see it:
// before: null is invisible in the signature
User findUser(String id); // might return null -- caller may not realise
// after: absence is explicit and unignorable
Optional<User> findUser(String id);Creating and consuming
Create with of (value must be non-null), ofNullable (wraps a possibly-null value), or empty(). Consume without ever calling get():
findUser(id)
.map(User::email) // transform if present
.filter(e -> e.endsWith("@corp.com"))
.ifPresentOrElse(
this::sendMail, // present branch
() -> log.warn("no corp email")); // empty branchThe fluent chain is the point: map, filter, ifPresent let you express 'if there is a value, do this' without an explicit null check, and the empty case flows through harmlessly.
map vs flatMap
As with streams, the distinction is whether your function returns a plain value or another Optional. Use map when the function returns a value; flatMap when it returns an Optional, to avoid a nested Optional<Optional<T>>:
user.map(User::name) // Optional<String>
user.flatMap(User::findManager) // findManager returns Optional<User>orElse vs orElseGet vs orElseThrow
Three ways to exit the Optional, and a real performance difference between the first two:
| Method | Behaviour |
|---|---|
orElse(v) | return v if empty — v is always evaluated |
orElseGet(supplier) | call supplier only if empty — lazy |
orElseThrow(supplier) | throw if empty |
opt.orElse(expensiveDefault()); // expensiveDefault() runs EVEN when opt is present
opt.orElseGet(() -> expensiveDefault()); // runs only when opt is emptyUse orElseGet whenever the fallback is non-trivial to compute or has side effects — orElse evaluates its argument eagerly regardless of whether the value is present, a subtle source of wasted work and surprising bugs.
The anti-patterns
Optional is designed for one job — return types — and misused everywhere else:
Do not use it for fields. It adds an allocation per field, is not Serializable, and adds nothing over a nullable field with a clear accessor. Do not use it for method parameters — it forces callers to wrap arguments and does not actually prevent null (someone can still pass null for the Optional itself); an overload or a nullable parameter is cleaner. Do not call get() without checking — an unguarded get() on an empty Optional throws NoSuchElementException, which is just NullPointerException with extra steps. If you find yourself writing if (opt.isPresent()) opt.get(), replace it with map/ifPresent.
Optional and streams
Optional composes with the Stream API. Stream methods like findFirst() and max() return Optional, and Optional.stream() (JDK 9) turns an Optional into a zero-or-one-element stream, which makes flattening a stream of Optionals clean:
List<User> found = ids.stream()
.map(this::findUser) // Stream<Optional<User>>
.flatMap(Optional::stream) // drop the empties, unwrap the rest
.toList();Optional as a return type to make absence explicit and kill NPEs at API boundaries — and only there. Chain map/flatMap/filter/ifPresent instead of calling get(). Prefer orElseGet over orElse when the fallback is expensive (orElse evaluates eagerly). Never use it for fields or parameters, and never call get() unguarded.