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);
Advertisement

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 branch

The 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:

MethodBehaviour
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 empty

Use 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.

Advertisement

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();
Use 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.