You look up an order by id. Sometimes there is no row. Returning null makes absence a surprise at every call site: if (order != null), then another if for the customer, then an NPE in production because one path skipped the check.
Returning Optional<Order> makes absence part of the type. The caller must choose a default, a throw, or a side effect. That is the job. Wrapping every getter, field, and argument in Optional is not.
Optional is a return type for 0 or 1 values — not a field, not a method argument, and not a way to sprinkle .ofNullable on every call.
The lookup that returned null
The pre-Optional contract is a comment and a prayer:
/** @return the order, or null if missing */
Order findOrder(String id) {
return orders.get(id); // HashMap.get: null on miss
}
Order order = findOrder(id);
String email = order.customerEmail(); // NPE when the map missed
The same lookup as an Optional return forces the miss to be handled here, not three frames later:
Optional<Order> findOrder(String id) {
return Optional.ofNullable(orders.get(id));
}
String email = findOrder(id)
.map(Order::customerEmail)
.orElse("guest@example.com");
map is a Function slot; orElse is the cheap constant. You never wrote if (order != null). The type already said the row might be missing.
When it shipped
| Release | Status | Spec |
|---|---|---|
| Java 8 | Standard | java.util.Optional (with lambda / streams, JSR 335) |
| Java 9 | ifPresentOrElse, or, stream | — |
| Java 10 | no-arg orElseThrow() | — |
| Java 11 | isEmpty() | — |
No preview flag. Optional is ordinary API on every Java 8+ JDK. Later methods are convenience; the mental model did not change.
Streams findFirst / findAny / reduce without an identity already return Optional. This post is that type, not those terminals.
Mental model
Think of Optional<T> as a box that holds a T or nothing:
- Present — there is a value.
map/filter/ifPresentsee it. - Empty — there is no value. Those operations are no-ops; unwrap methods supply a default or throw.
| Idea | Meaning |
|---|---|
| Job | Return type for “maybe a T” |
| Size | Zero or one. Never a list. |
| Empty | Optional.empty(), not null Optional |
| Present | Optional.of(value) — value must not be null |
| Maybe-null source | Optional.ofNullable(value) |
| Not for | Fields, parameters, collections, wrapping every getter |
An empty Optional is a real object. Returning null from a method declared Optional<Order> is the old bug in a new costume — the caller now NPEs on the Optional itself.
Create: of, ofNullable, empty
Three factories. Pick by whether you already know the value is non-null.
Optional<Order> present = Optional.of(guestOrder); // NPE if guestOrder is null
Optional<Order> maybe = Optional.ofNullable(orders.get(id));
Optional<Order> nobody = Optional.empty();
of is the “I have a T” factory. Pass a null and it fails now, which is what you want when a constructor argument was supposed to be required.
ofNullable is the bridge from APIs that still return null — Map.get, JDBC getString, a legacy finder. Use it at the boundary, once, then stay in Optional methods.
Note: Optional.of(null) throws NullPointerException. That is not a style debate. If the source might be null, it is ofNullable, not of.
Transform: map, flatMap, filter
Once you have an Optional<Order>, do not get() it out to call methods. Stay in the box.
map applies a Function when present and leaves empty alone:
Optional<String> email = findOrder(id)
.map(Order::customerEmail);
If the function returns null, map becomes empty — it does not become Optional.of(null), which is illegal anyway:
Optional<String> loyalty = findOrder(id)
.map(Order::loyaltyTier); // null tier → empty Optional, not NPE
filter keeps the value only when a Predicate is true. Present + false → empty. Empty stays empty:
Optional<Order> billable = findOrder(id)
.filter(order -> order.total().compareTo(new BigDecimal("10")) >= 0);
flatMap is map when the function already returns an Optional. Nested Optional<Optional<Customer>> is a bug; flatten it:
Optional<Customer> customer = findOrder(id)
.flatMap(order -> findCustomer(order.customerId()));
Optional<String> email = findOrder(id)
.flatMap(order -> findCustomer(order.customerId()))
.map(Customer::email);
findCustomer returns Optional<Customer>. map(this::findCustomer) would wrap that in another Optional. flatMap unwraps one layer. Same idea as Stream.flatMap.
Note: The mapper you pass to flatMap must return an Optional, never null. A null mapper result is an NPE, not empty. Return Optional.empty() when the inner lookup misses.
Unwrap: orElse, orElseGet, orElseThrow
Sooner or later you need a T, not an Optional<T>. Three exits, different when.
orElse takes a value and always evaluates its argument, even on a hit:
Order order = findOrder(id)
.orElse(loadGuestOrderFromDisk()); // disk hit even when findOrder succeeded
orElseGet takes a Supplier. The supplier runs only on empty:
Order order = findOrder(id)
.orElseGet(this::loadGuestOrderFromDisk);
Use orElse for a cheap constant (orElse(Order.GUEST)). Use orElseGet the moment the fallback is I/O, a graph of objects, or anything you do not want to pay on the hit path. The Supplier post owns that slot; this is why Optional asks for it.
orElseThrow takes a Supplier of the exception, so you do not build the exception on the hit path either:
Order order = findOrder(id)
.orElseThrow(() -> new OrderNotFoundException(id));
Java 10 added a no-arg orElseThrow() that throws NoSuchElementException. Fine in a pipeline where “must be present” is already an invariant. Prefer the supplier form when the exception should name the missing id.
Do not call optional.get() without a present check. get() on empty throws NoSuchElementException — the same crash as a null dereference, with a worse stack. orElseThrow is the honest “this must exist” form; ifPresent is the “do this if it exists” form. isPresent + get is a null check with extra syntax:
// don't: recreates if (order != null)
if (maybe.isPresent()) {
charge(maybe.get());
}
// do
maybe.ifPresent(this::charge);
Order order = maybe.orElseThrow(() -> new OrderNotFoundException(id));
Act: ifPresent, ifPresentOrElse
When the next step is a side effect, not a value, do not unwrap into a local just to call a method.
findOrder(id).ifPresent(notifier::notifyPaid);
Empty → nothing happens. Present → accept runs. That is a Consumer slot; Optional does not care what the side effect is.
ifPresentOrElse (Java 9) adds the empty path as a Runnable:
findOrder(id).ifPresentOrElse(
notifier::notifyPaid,
() -> log.info("no order {}", id));
Use it when both arms are effects. If either arm produces a value you need, that is map + orElse / orElseGet, not ifPresent.
Do not wrap every call
Optional is cheap compared to an NPE in production and expensive compared to returning the value you already have. Cargo-cult wrapping shows up in four shapes.
Fields. A class with private Optional<String> email still has a null field if someone forgets to initialize it — now you have two absences. Serialization, JPA, and records all fight Optional as state. Store String email (or a sentinel the domain owns) and return Optional from the accessor if callers need the maybe.
Method arguments. void charge(Optional<Order> order) gives the caller two ways to pass nothing: Optional.empty() and null. You will get both. Take Order and require it, or overload / split the API. Rare JDK methods take Optional as a parameter; copy that only when you are writing the same kind of library combinator.
Collections. An empty List already means “no items.” Do not return Optional<List<Order>> — return List<Order>, empty on miss:
List<Order> findByCustomer(String customerId) {
return ordersByCustomer.getOrDefault(customerId, List.of());
}
Optional.of(list) where list is empty is still present. Callers write if (optional.isPresent() && !optional.get().isEmpty()), which is the null check you thought you deleted.
Every getter at the call site. Optional.ofNullable(order.getCustomer()) on every use is not a design. If absence is real, findCustomer returns Optional<Customer> once. If the customer is required after load, return Customer and throw at the boundary. Wrapping at fifty call sites means fifty places to forget.
Primitive optionals, briefly
Optional<Integer> boxes. For a hot int that might be missing — an index, a count, a status code — the JDK ships OptionalInt, OptionalLong, and OptionalDouble. Same idea: of, empty, orElse, orElseGet, ifPresent. No map chain as rich as Optional<T>; they exist to skip the box, not to replace object Optional in domain code.
Prefer Optional<Order> and Optional<BigDecimal> in application code. Reach for OptionalInt when you already have an IntStream terminal (findFirst on ints, max, average → OptionalDouble) or a profiler pointing at Optional<Integer> allocations.
Cheat sheet
One screen for the factories, the chain, and the habits that matter:
Return type for 0 or 1 values — not a field, not a parameter, not a List wrapper
Create Optional.of(x) x non-null or NPE
Optional.ofNullable(x) null → empty
Optional.empty()
Transform map(fn) fn returns null → empty
flatMap(fn) fn already returns Optional
filter(pred) present + false → empty
Unwrap orElse(x) always evaluate x
orElseGet(supplier) supplier.get() only on empty
orElseThrow(supplier) exception only on empty
get() don't; throws on empty
Act ifPresent(consumer)
ifPresentOrElse(c, run) Java 9
Primitives OptionalInt / Long / Double — skip boxing on hot ints, not a default
Do:
- Return
Optional<T>from finders, lookups, and Stream terminals that might miss. - Stay in
map/flatMap/filteruntil you unwrap once at the edge. - Use
orElsefor a constant,orElseGet/orElseThrowfor work.
Don’t:
- Wrap every method, field, or argument in
Optional. - Return
Optional<List<T>>— an empty list is absence. - Call
get()becauseisPresentwas true; preferorElseThrow/ifPresent. - Return
nullfrom a method declaredOptional<T>.
Wrap-up
Optional<T> is how Java 8 says “this method might not have a T” without null. Create at the boundary with of / ofNullable / empty. Transform with map, flatMap, and filter. Unwrap with orElse (eager), orElseGet (lazy Supplier), or orElseThrow. Act with ifPresent when the next step is a side effect.
Use it as a return type. Leave fields, parameters, and collections alone. The cargo-cult version — Optional on every getter, get() behind isPresent, Optional<List<Order>> — puts the null checks back and adds a box.
The next Java 8 type that also means “maybe later” is async: compose work without a callback pyramid.