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

ReleaseStatusSpec
Java 8Standardjava.util.Optional (with lambda / streams, JSR 335)
Java 9ifPresentOrElse, or, stream—
Java 10no-arg orElseThrow()—
Java 11isEmpty()—

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 / ifPresent see it.
  • Empty — there is no value. Those operations are no-ops; unwrap methods supply a default or throw.
IdeaMeaning
JobReturn type for “maybe a T”
SizeZero or one. Never a list.
EmptyOptional.empty(), not null Optional
PresentOptional.of(value) — value must not be null
Maybe-null sourceOptional.ofNullable(value)
Not forFields, 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 / filter until you unwrap once at the edge.
  • Use orElse for a constant, orElseGet / orElseThrow for work.

Don’t:

  • Wrap every method, field, or argument in Optional.
  • Return Optional<List<T>> — an empty list is absence.
  • Call get() because isPresent was true; prefer orElseThrow / ifPresent.
  • Return null from a method declared Optional<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.

Next optional step in the series Compose async work without callback hell. CompletableFuture: Compose Async Work Without Callback Hell