Command Palette

Search for a command to run...

[Advanced Java] Optional in Java: A Return Type, Not a Cure for NullPointerException

Optional is usually introduced as the way to avoid NullPointerException. That framing is wrong, and it is wrong in a way that produces worse code than not using the type at all. Optional does not remove null from Java, cannot remove it, and was never intended to. Every reference type in the language still accepts null, an Optional variable can itself be null, and every Optional you create is one more object between you and the value.

What it was actually added for, in Java 8, is narrow and useful: a method whose return type is Optional states in its own signature that it may have nothing to give back, so the caller cannot forget the miss and the compiler will not let them treat "nothing" as a value. Used in that one position it is excellent. Used as a general null replacement — in fields, in parameters, inside collections — it adds allocation, indirection and a third state to reason about, and gives nothing back.

Three chips under a large Optional glyph: present, empty, and null still in the language

Every program output, exception message and javac error quoted below was produced by compiling and running the code on OpenJDK 21.0.6 (arm64). Where a method belongs to a release later than Java 8, the release is named and was confirmed by compiling the same call with javac --release 8, 9, 10 and 11.

Optional is a return type, not a replacement for null

Start from what a method signature can and cannot say. A method declared User findById(long id) promises a User. Nothing in that signature tells the caller that a missing row comes back as null, so the only defences are documentation nobody reads and a null check nobody writes.

User findRaw(long id) { ... }        // may return null — you have to know
Optional<User> find(long id) { ... } // may be empty — the type says so

That is the entire contribution. Optional moves a fact that used to live in a Javadoc comment into the type system, where the compiler enforces it: you cannot call getEmail() on an Optional<User>, so you must decide what happens when there is no user before you can compile.

Nothing else changes. null is still a legal value of every reference type, including Optional itself. The type is not Serializable, not Comparable, and implements no interface at all — reflection over java.util.Optional in Java 21 reports zero implemented interfaces and java.lang.Object as its superclass. It is final, and it carries the internal @ValueBased annotation, which is why javac -Xlint:all warns "attempt to synchronize on an instance of a value-based class" if you use one as a lock.

⚠️ If you take one thing from this article: Optional belongs on the way out of a method. null is still fine inside an object. Sprinkling Optional over fields and parameters to "get rid of null" makes code longer, slower and harder to read, and the sections below show exactly how.

The three states, and the fourth one that defeats the point

An Optional is a single-slot container. It is either present — a value is inside — or empty. That is two states, and the JDK models them with one shared instance for empty and a fresh object for each present value.

Four cards: present, empty, of(null) throwing, and a null Optional reference throwing

Optional<String> present = Optional.of("hoang");
Optional<String> empty = Optional.empty();

System.out.println("present  = " + present);
System.out.println("empty    = " + empty);
System.out.println("present.isPresent() = " + present.isPresent());
System.out.println("empty.isEmpty()     = " + empty.isEmpty());

Optional<String> fromNull = Optional.ofNullable(null);
System.out.println("ofNullable(null) = " + fromNull);
System.out.println("same object as Optional.empty()? " + (fromNull == Optional.<String>empty()));
present  = Optional[hoang]
empty    = Optional.empty
present.isPresent() = true
empty.isEmpty()     = true
ofNullable(null) = Optional.empty
same object as Optional.empty()? true

toString is worth memorising, because it is what you will see in a log: a present Optional prints Optional[value], an empty one prints Optional.empty. Optional.empty() hands back the same cached instance every time, so the reference comparison above is true; Optional.of("hi") == Optional.of("hi") is false, because each call allocates.

Optional.of(null) fails at the call, not later

The two factories differ in exactly one way, and the difference is deliberate. of calls Objects.requireNonNull on its argument; ofNullable does not.

try {
    Optional<String> bad = Optional.of(null);
    System.out.println("never reached " + bad);
} catch (NullPointerException e) {
    System.out.println("Optional.of(null) threw " + e);
}
Optional.of(null) threw java.lang.NullPointerException

Left uncaught, the stack trace names the exact frame:

Exception in thread "main" java.lang.NullPointerException
	at java.base/java.util.Objects.requireNonNull(Objects.java:233)
	at java.base/java.util.Optional.of(Optional.java:113)
	at OfNullThrows.main(OfNullThrows.java:6)

This is a feature, not a wart. Optional.of(x) is an assertion that x is not null, and it fails at the line where the wrong value was handed over instead of three frames later. Use of when you know the value exists, ofNullable when you are wrapping something that may be null — typically the result of a legacy API or a Map lookup.

An Optional variable that is itself null

Here is the state people expect to be impossible, and it is the reason the "no more NullPointerException" framing collapses.

Optional<String> broken = null;
System.out.println(broken.isPresent());
Exception in thread "main" java.lang.NullPointerException: Cannot invoke "java.util.Optional.isPresent()" because "broken" is null
	at NullVar.main(NullVar.java:5)

The container is a reference like any other, so a null in that slot throws on the first call and takes the entire benefit of the type with it. The message names the variable because the class was compiled with debug information — javac -g, which IDEs and build tools do by default. Compile the same file without it and the JVM prints because "<local4>" is null instead. That message format is the helpful-NullPointerException work covered in the basics course; it is a diagnostic aid, not a fix.

The rule that follows is absolute: a variable, field or return value of type Optional is never null. Once a method is able to return Optional.empty(), it has no remaining reason to return null, and any code that both returns Optional and returns null has taken on the cost of the type while keeping the bug it was meant to remove.

The whole API, grouped by what each method is for

Optional declares twenty public methods, and three of them are equals, hashCode and toString. The remaining seventeen fall into four groups, and grouping them by purpose makes the API far easier to hold in your head than an alphabetical list does.

Creation

CallWhat it doesWith a null argument
Optional.of(value)wraps a value that must not be nullthrows NullPointerException immediately
Optional.ofNullable(value)wraps a value that may be nullreturns the empty instance
Optional.empty()the shared empty instancenot applicable

Interrogation

CallReturns
isPresent()true when a value is inside
isEmpty()true when there is none — added in Java 11

Both exist so you can ask; neither is usually the right thing to call. A chain that ends in orElse or map says the same thing without branching, and the next two sections explain why the branching version is worse.

Extraction

CallWhen emptyEvaluates its argument
get()throws NoSuchElementExceptionnot applicable
orElseThrow()throws NoSuchElementException — added in Java 10not applicable
orElse(other)returns otheralways, present or not
orElseGet(supplier)calls the supplieronly when empty
orElseThrow(supplier)throws whatever the supplier buildsonly when empty

Transformation

CallMapper returnsResult
map(f)a plain valueOptional of that value, empty if f returns null
flatMap(f)an Optionalthat same Optional, not a nested one
filter(p)a booleanthe value if the predicate passes, empty otherwise
or(supplier)an Optionalthis one if present, otherwise the supplier's — Java 9
ifPresent(action)nothingruns the action only when present
ifPresentOrElse(action, empty)nothingone branch or the other — Java 9
stream()nothinga stream of zero or one element — Java 9

Here is the transformation group running against a value that needs trimming:

Optional<String> name = Optional.of("  Hoang  ");
Optional<String> blank = Optional.of("   ");
Optional<String> none = Optional.empty();

System.out.println("map        : " + name.map(String::trim).map(String::toUpperCase));
System.out.println("filter hit : " + name.map(String::trim).filter(s -> !s.isEmpty()));
System.out.println("filter miss: " + blank.map(String::trim).filter(s -> !s.isEmpty()));
System.out.println("or         : " + none.or(() -> Optional.of("anonymous")));
System.out.println("map on empty: " + none.map(String::toUpperCase));

name.ifPresent(v -> System.out.println("ifPresent  : got [" + v.trim() + "]"));
none.ifPresentOrElse(
        v -> System.out.println("ifPresentOrElse: value " + v),
        () -> System.out.println("ifPresentOrElse: nothing there"));

System.out.println("stream present: " + name.map(String::trim).stream().toList());
System.out.println("stream empty  : " + none.stream().toList());
map        : Optional[HOANG]
filter hit : Optional[Hoang]
filter miss: Optional.empty
or         : Optional[anonymous]
map on empty: Optional.empty
ifPresent  : got [Hoang]
ifPresentOrElse: nothing there
stream present: [Hoang]
stream empty  : []

Two details in that output pay for themselves. filter turns a present value that fails the predicate into Optional.empty, which is how a blank string becomes "no name" in one call. And map on an empty Optional returns empty without invoking the mapper at all — the short-circuit behaviour demonstrated with a counter later on.

Which release added what

ReleaseAdded
Java 8of, ofNullable, empty, get, isPresent, ifPresent, filter, map, flatMap, orElse, orElseGet, orElseThrow(Supplier)
Java 9or, ifPresentOrElse, stream
Java 10orElseThrow() with no argument
Java 11isEmpty

That table was produced by compiling one call per method with javac --release 8, --release 9, --release 10 and --release 11 and recording the first release that accepted it, not from memory. It matters in practice: a codebase pinned to Java 8 has neither or nor ifPresentOrElse nor the no-argument orElseThrow, and isEmpty — the most obviously missing method in the original design — did not arrive until Java 11.

orElse versus orElseGet: one of them always runs

This is the single most misunderstood pair in the API, and the mistake is invisible in a code review because both lines read the same. orElse takes a value, so Java evaluates its argument before the call happens — present or not. orElseGet takes a Supplier, so the body runs only if the Optional turns out to be empty.

static String expensiveDefault() {
    System.out.println("  >> expensiveDefault() ran");
    return "DEFAULT";
}

public static void main(String[] args) {
    Optional<String> present = Optional.of("cached");
    Optional<String> empty = Optional.empty();

    System.out.println("present.orElse(expensiveDefault())");
    System.out.println("  result = " + present.orElse(expensiveDefault()));

    System.out.println("present.orElseGet(EagerDefault::expensiveDefault)");
    System.out.println("  result = " + present.orElseGet(EagerDefault::expensiveDefault));

    System.out.println("empty.orElse(expensiveDefault())");
    System.out.println("  result = " + empty.orElse(expensiveDefault()));

    System.out.println("empty.orElseGet(EagerDefault::expensiveDefault)");
    System.out.println("  result = " + empty.orElseGet(EagerDefault::expensiveDefault));
}
present.orElse(expensiveDefault())
  >> expensiveDefault() ran
  result = cached
present.orElseGet(EagerDefault::expensiveDefault)
  result = cached
empty.orElse(expensiveDefault())
  >> expensiveDefault() ran
  result = DEFAULT
empty.orElseGet(EagerDefault::expensiveDefault)
  >> expensiveDefault() ran
  result = DEFAULT

Read the first block again. The Optional was present, the result was cached, and expensiveDefault() still ran — before the call, because that is what evaluating an argument means. The orElseGet version on the same present Optional printed nothing at all.

The same trap with a real cost

A println makes the mechanism visible; a real default makes it expensive. Here the fallback is a lookup that counts its own invocations, and the loop asks for three users that exist and one that does not.

static int dbHits = 0;

static String loadDefaultFromDb() {
    dbHits++;
    return "en-US";
}

static Optional<String> lookupUserLocale(String user) {
    return "hoang".equals(user) ? Optional.of("vi-VN") : Optional.empty();
}
dbHits = 0;
for (String u : new String[] { "hoang", "hoang", "hoang", "ghost" }) {
    lookupUserLocale(u).orElse(loadDefaultFromDb());
}
System.out.println("db hits with orElse    = " + dbHits);

dbHits = 0;
for (String u : new String[] { "hoang", "hoang", "hoang", "ghost" }) {
    lookupUserLocale(u).orElseGet(RealOrElse::loadDefaultFromDb);
}
System.out.println("db hits with orElseGet = " + dbHits);
db hits with orElse    = 4
db hits with orElseGet = 1

Four lookups instead of one, for four iterations of which only one needed a default. Scale that to a request handler and orElse has quadrupled the load on whatever the default came from. Worse, if the default has a side effect — inserting a row, incrementing a counter, sending a message — orElse performs it every single time, including when the value was there.

The rule is mechanical and worth applying without thinking about it:

  • orElse only when the default is a constant or an already-computed value: orElse(""), orElse(0), orElse(Collections.emptyList()).
  • orElseGet whenever producing the default calls anything.

orElse(null) deserves a note of its own: it is legal, it compiles, and it returns null.

System.out.println("empty.orElse(null) = " + Optional.empty().orElse(null));
empty.orElse(null) = null

That is occasionally the honest bridge back to a nullable API, and much more often a sign that the Optional should never have been created.

get() is the one method you should not call

get() returns the value if there is one and throws if there is not. The exception is specific and worth recognising on sight.

Optional<String> empty = Optional.empty();

try {
    empty.get();
} catch (NoSuchElementException e) {
    System.out.println("get()          -> " + e);
}

try {
    empty.orElseThrow();
} catch (NoSuchElementException e) {
    System.out.println("orElseThrow()  -> " + e);
}

try {
    empty.orElseThrow(() -> new IllegalStateException("user 42 not found"));
} catch (IllegalStateException e) {
    System.out.println("orElseThrow(s) -> " + e);
}
get()          -> java.util.NoSuchElementException: No value present
orElseThrow()  -> java.util.NoSuchElementException: No value present
orElseThrow(s) -> java.lang.IllegalStateException: user 42 not found

The first two lines are identical because since Java 10 they are the same method body under two names. orElseThrow() is the recommended spelling: get() reads like a safe accessor and behaves like an assertion, while orElseThrow() says out loud that it can throw. get() is not deprecated in Java 21 — Optional.class.getMethod("get").isAnnotationPresent(Deprecated.class) reports false, and javac -Xlint:all says nothing about a call to it — but the JDK's own documentation now steers you to the alternatives.

The supplier form is the one worth reaching for in application code, because NoSuchElementException: No value present tells whoever reads the log absolutely nothing about which lookup failed.

isPresent() followed by get() is a null check with more syntax

The pattern below is the most common thing people write after adopting Optional, and it is the one that convinces teams the type is not worth it — correctly, because in this shape it is not.

// a null check
User u = findRaw(id);
String a = (u != null) ? u.email() : "unknown";

// the same null check, with more syntax
Optional<User> o = find(id);
String b = o.isPresent() ? o.get().email() : "unknown";

// what Optional is actually for
String c = find(id).map(User::email).orElse("unknown");

All three produce unknown for a missing id. The middle one has the same branch as the first, the same chance of being written wrong, plus an allocation and an unfamiliar type — it is strictly worse than the null check it replaced. The third has no branch to get wrong at all.

The residual case for isPresent() is when the two branches do genuinely different work and neither produces a value, and even then ifPresentOrElse usually reads better. If your code is 80% isPresent() and get(), you have adopted the syntax of Optional without any of the benefit.

Chaining: map, flatMap, filter and or

Chaining is where the type stops being ceremony and starts removing code. Every transformation method returns another Optional, and every one of them does nothing when the receiver is empty.

A five-stage Optional pipeline traced twice, with map versus flatMap below

map when the mapper returns a value, flatMap when it returns an Optional

That single sentence is the whole rule, and picking wrong is a compile error rather than a bug at runtime — which is the good outcome.

map wraps whatever the mapper produced: give it a mapper returning Customer and you get Optional<Customer>. flatMap expects the mapper to have already wrapped its result, and hands that inner Optional straight back instead of nesting it. So when a getter is itself Optional-returning — which is exactly what this article recommends writing — map produces Optional<Optional<Customer>>:

Optional<Optional<Address>> nested =
        Optional.of(full).map(Order::rawCustomer).map(Customer::address);

System.out.println("map twice     -> " + nested);
System.out.println("flatMap twice -> " +
        Optional.of(full).flatMap(Order::customer).flatMap(Customer::address));
map twice     -> Optional[Optional[Address[city=Da Nang, zip=550000]]]
flatMap twice -> Optional[Address[city=Da Nang, zip=550000]]

Continue the chain after a map that should have been a flatMap and javac stops you:

return Optional.ofNullable(order)
        .map(Order::customer)      // Optional<Optional<Customer>>
        .map(Customer::address)    // does not compile
        .map(Address::zip)
        .orElse("UNKNOWN");
MapMisuse.java:18: error: incompatible types: invalid method reference
                .map(Customer::address)    // does not compile
                     ^
    method address in class Customer cannot be applied to given types
      required: no arguments
      found:    Optional<Customer>
      reason: actual and formal argument lists differ in length
Note: Some messages have been simplified; recompile with -Xdiags:verbose to get full output
1 error

The mirror-image mistake — flatMap with a mapper that returns a plain value — is caught just as loudly, and the message spells out the required type:

FlatMisuse.java:10: error: method flatMap in class Optional<T> cannot be applied to given types;
        Optional<Customer> c = opt.flatMap(Order::rawCustomer);
                                  ^
  required: Function<? super Order,? extends Optional<? extends U>>
  found:    Order::rawCustomer
  reason: cannot infer type-variable(s) U
    (argument mismatch; bad return type in method reference
      Customer cannot be converted to Optional<? extends U>)

Four null checks become one expression

Take a model where an order may have no customer, a customer may have no address, and an address may have no postal code — the shape every real system eventually grows.

static String zipNullChecks(Order order) {
    if (order == null) return "UNKNOWN";
    Customer c = order.rawCustomer();
    if (c == null) return "UNKNOWN";
    Address a = c.rawAddress();
    if (a == null) return "UNKNOWN";
    String zip = a.zip();
    if (zip == null) return "UNKNOWN";
    return zip;
}

static String zipChained(Order order) {
    return Optional.ofNullable(order)
            .flatMap(Order::customer)
            .flatMap(Customer::address)
            .map(Address::zip)
            .orElse("UNKNOWN");
}

Both return 550000 for a complete order and UNKNOWN for every partial one, including a null order. The chained version has one exit instead of five and no way to forget a level, and adding a level to the model adds one line to it rather than three.

Tracing each stage shows why: once a stage produces empty, everything after it produces empty too.

== full
  1 ofNullable(order)        Optional[order]
  2 flatMap(Order::customer) Optional[customer]
  3 flatMap(Cust::address)   Optional[address]
  4 map(Address::zip)        Optional[550000]
  5 orElse("UNKNOWN")        "550000"
== customer is null
  1 ofNullable(order)        Optional[order]
  2 flatMap(Order::customer) Optional.empty
  3 flatMap(Cust::address)   Optional.empty
  4 map(Address::zip)        Optional.empty
  5 orElse("UNKNOWN")        "UNKNOWN"

"Produces empty" is not the same as "runs and produces empty". The mappers of the later stages are never invoked, which a counter proves:

AtomicInteger calls = new AtomicInteger();

calls.set(0);
present.map(s -> { calls.incrementAndGet(); return s.toUpperCase(); })
       .filter(s -> { calls.incrementAndGet(); return true; })
       .map(s -> { calls.incrementAndGet(); return s + "!"; });
System.out.println("present: mapper calls = " + calls.get());

calls.set(0);
empty.map(s -> { calls.incrementAndGet(); return s.toUpperCase(); })
     .filter(s -> { calls.incrementAndGet(); return true; })
     .map(s -> { calls.incrementAndGet(); return s + "!"; });
System.out.println("empty:   mapper calls = " + calls.get());
present: mapper calls = 3
empty:   mapper calls = 0

That is what makes a long chain safe: no lambda in it ever sees a null, and none of them runs at all on the miss path.

filter, or, and the side-effect methods

filter narrows a present value to empty when a predicate fails, which is how validation joins the chain without an if. or supplies a whole replacement Optional and, like orElseGet, is lazy — the suppliers after the first hit are never called:

Optional<String> resolved = Optional.<String>empty()
        .or(() -> Optional.empty())
        .or(() -> Optional.of("from config"))
        .or(() -> Optional.of("never reached"));

System.out.println(resolved);
Optional[from config]

One caution on map: because it wraps its result with ofNullable, a mapper that returns null gives you empty rather than Optional[null].

System.out.println("map(s -> null) = " + Optional.of("x").map(s -> null));
map(s -> null) = Optional.empty

Convenient when you are mapping through a legacy getter, and a silent absence when the null was actually a bug — so it is worth knowing which of the two you are relying on.

Where Optional makes code worse

Everything above is the case for Optional. This section is the case against using it anywhere except a return type, and each item is a real cost rather than a style preference.

One class with five red markers on misused Optional positions and a green marker on the return type

Optional as a field

The claim usually made is that Optional is not Serializable. It is worth checking rather than repeating, so here is the check and the result of actually attempting serialisation:

static class User implements Serializable {
    private static final long serialVersionUID = 1L;
    private final String name;
    private final Optional<String> nickname;   // Optional as a field

    User(String name, String nickname) {
        this.name = name;
        this.nickname = Optional.ofNullable(nickname);
    }
}

System.out.println("Optional implements Serializable? "
        + Serializable.class.isAssignableFrom(Optional.class));

User u = new User("hoang", "hoangth");
try (ObjectOutputStream out = new ObjectOutputStream(new ByteArrayOutputStream())) {
    out.writeObject(u);
    System.out.println("serialized fine");
} catch (NotSerializableException e) {
    System.out.println("writeObject threw " + e);
}
Optional implements Serializable? false
writeObject threw java.io.NotSerializableException: java.util.Optional

The class compiles, the object constructs, and the failure arrives at run time the first time anything tries to write it — a session store, a cache, a remote call, an old-style deep copy. Serialisation is also the smaller half of the argument. An Optional field costs one extra object per instance for a fact the field could carry itself, it cannot be a primitive, and frameworks that populate fields reflectively will happily leave it null, restoring exactly the failure mode you adopted the type to avoid.

Keep the field nullable and put the Optional on the accessor, which is the boundary the type was designed for:

static class User implements Serializable {
    private static final long serialVersionUID = 1L;
    private final String name;
    private final String nickname;          // nullable field

    User(String name, String nickname) { this.name = name; this.nickname = nickname; }

    Optional<String> nickname() { return Optional.ofNullable(nickname); }
}

Writing a User that has no nickname through an ObjectOutputStream and reading it back:

serialized 90 bytes
nickname() = Optional.empty

The object serialises, and every caller still gets a signature that admits the nickname may be absent.

Optional as a method parameter

A reference parameter has two cases the caller must handle: a value, or null. Declaring it Optional makes three, because the argument itself can be null.

static String greetBad(String name, Optional<String> title) {
    return title.map(t -> t + " " + name).orElse(name);
}

System.out.println(greetBad("Hoang", Optional.of("Dr.")));
System.out.println(greetBad("Hoang", Optional.empty()));
System.out.println(greetBad("Hoang", null));   // the third case
Dr. Hoang
Hoang
greetBad(name, null) -> Cannot invoke "java.util.Optional.map(java.util.function.Function)" because "title" is null

You have not removed a failure mode, you have added one — and made every call site noisier, because a caller with a plain value now has to write Optional.of(title) at the call. Two overloads say the same thing with no wrapper and no third case:

static String greet(String name) { return name; }
static String greet(String name, String title) { return title + " " + name; }
Hoang
Dr. Hoang

Each overload is total: there is no argument value that makes either one misbehave.

Optional inside a collection

A Map already has a complete answer for a missing key, and wrapping the value type gives you two different kinds of nothing that mean the same thing.

Map<String, Optional<String>> bad = new HashMap<>();
bad.put("hoang", Optional.of("hth"));
bad.put("linh", Optional.empty());

System.out.println("bad.get(\"linh\")   = " + bad.get("linh"));
System.out.println("bad.get(\"absent\") = " + bad.get("absent"));

Map<String, String> good = new HashMap<>();
good.put("hoang", "hth");
System.out.println("Optional.ofNullable(good.get(\"absent\")) = "
        + Optional.ofNullable(good.get("absent")));
bad.get("linh")   = Optional.empty
bad.get("absent") = null
Optional.ofNullable(good.get("absent")) = Optional.empty

The Optional-valued map now distinguishes "present with no value" from "not present", and every reader has to work out whether that distinction is meaningful in this particular map — usually it is not. It also allocates an Optional per entry. The plain map plus one ofNullable at the lookup gives the same result with one kind of absence. The same reasoning rules out List<Optional<T>>: a list of maybe-values is nearly always a list that should have had the misses filtered out of it.

Returning null from a method that returns Optional

This is the worst combination available, because it is the only one that is strictly worse than doing nothing at all.

static Optional<String> findNickname(String user) {
    return null;
}

try {
    System.out.println(findNickname("hoang").orElse("none"));
} catch (NullPointerException e) {
    System.out.println("caller NPE: " + e.getMessage());
}
caller NPE: Cannot invoke "java.util.Optional.orElse(Object)" because the return value of "Misuse.findNickname(String)" is null

The caller read the signature, trusted it, chained on the result, and got the exception the type exists to prevent — while paying for the wrapper. The JVM message is at least precise about the culprit: it names the method whose return value was null. A method with an Optional return type has exactly two legal returns, Optional.of(...)/Optional.ofNullable(...) and Optional.empty(), and return null; in such a body should be treated as a compile error by review even though javac accepts it.

Optional for a value that is never absent

The last one has no exception to show, which is why it survives so long in a codebase. Wrapping something that is always there costs an allocation on every call and forces every caller to unwrap a value that was never missing:

Optional<Long> version() {
    return Optional.of(this.version);   // never absent
}

Optional.of(x) allocates: two calls with the same content are different objects, as Optional.of("hi") == Optional.of("hi") returning false shows. Only Optional.empty() is shared. Whether one small short-lived object per call matters depends entirely on the call rate and is not worth measuring here — but there is no upside to weigh it against, because the caller learns nothing from a type that says "maybe" about something that is always present. If the value is mandatory, return it. Optional on a field or a return that cannot be empty is pure noise, and worse, it trains readers to stop believing the type when it does mean something.

Where Optional genuinely belongs

Three shapes account for nearly every good use, and all three are the same shape underneath: a method that answers a question to which "nothing" is a normal answer rather than an error.

/* 1. A repository lookup: "not found" is a normal answer. */
Optional<User> findById(long id) {
    return Optional.ofNullable(rows.get(id));
}

/* 2. A parse that can fail, without using an exception for control flow. */
static Optional<Integer> parseInt(String s) {
    try {
        return Optional.of(Integer.valueOf(s));
    } catch (NumberFormatException e) {
        return Optional.empty();
    }
}

/* 3. A search with no match. */
static Optional<String> firstLongerThan(List<String> words, int n) {
    return words.stream().filter(w -> w.length() > n).findFirst();
}
hoang@example.com
no such user
parse 42   : Optional[42]
parse abc  : Optional.empty
parse abc with default: -1
first > 2  : Optional[ccc]
first > 9  : Optional.empty

At the call site the signature does the work. A caller who wants a default writes orElse, a caller for whom the miss really is an error writes orElseThrow with a message that identifies the lookup, and neither can accidentally use a missing value as if it were present:

String email = repo.findById(id)
        .map(User::email)
        .orElseThrow(() -> new NoSuchElementException("user " + id));
Exception in thread "main" java.util.NoSuchElementException: user 99
	at GoodUses.lambda$main$1(GoodUses.java:50)
	at java.base/java.util.Optional.orElseThrow(Optional.java:403)
	at GoodUses.main(GoodUses.java:50)

Optional.stream() is the bridge when a batch of those lookups feeds a pipeline: it turns each result into zero or one element, so the misses disappear without a filter plus get pair.

List<String> found = ids.stream()
        .map(OptStream::findById)
        .flatMap(Optional::stream)
        .map(User::email)
        .sorted()
        .toList();
[hoang@example.com, linh@example.com]
asked for 4, found 2

The JDK's own uses, and why Map.get still returns null

The library is a good guide to the intended scope, because it adopted the type only where a genuinely new method was being added. Reflecting over the interfaces in Java 21, java.util.stream.Stream declares exactly five Optional-returning methods — findFirst, findAny, max, min and reduce — and java.util.Map declares none.

Stream (5): [findAny -> Optional, findFirst -> Optional, max -> Optional, min -> Optional, reduce -> Optional]
Map (0): []
IntStream (6): [average -> OptionalDouble, findAny -> OptionalInt, findFirst -> OptionalInt, max -> OptionalInt, min -> OptionalInt, reduce -> OptionalInt]
Map.get on a missing key: null

The pattern is exact. Stream arrived in Java 8 alongside Optional, so its terminal operations that may find nothing were free to use it. Map has existed since Java 1.2 and Map.get is specified to return null for a missing key. Changing that return type is impossible in both directions: it would break source compatibility for every caller of get ever written, break binary compatibility because the return type is part of a method's descriptor in the class file, and break every class that implements Map outside the JDK. It would also destroy HashMap's documented behaviour of allowing a null value, which is precisely why getOrDefault and containsKey exist. Map.get returning null is not an oversight the JDK regrets; it is a contract it cannot change.

The primitive variants in that output are worth a line too. OptionalInt, OptionalLong and OptionalDouble exist only to avoid boxing in the stream API, they have no map or flatMap, and they are not general-purpose types to reach for in your own code.

Practical rules

  • Return Optional. Do not store it in a field, accept it as a parameter, or put it in a collection.
  • A value of type Optional is never null. return null; from an Optional-returning method is a bug, not a shortcut.
  • Optional.of when the value must exist, Optional.ofNullable when wrapping something that might not.
  • orElse for constants only. Anything that calls something goes in orElseGet.
  • Prefer orElseThrow() to get(), and prefer orElseThrow(supplier) to both, so the failure names the lookup.
  • isPresent() plus get() is a null check wearing a costume. Reach for map, filter, orElse and ifPresentOrElse first.
  • flatMap exactly when the mapper itself returns an Optional; map otherwise. Getting it wrong is a compile error, not a runtime surprise.
  • Do not wrap a value that is always present, and do not wrap a collection — return an empty collection instead.

FAQ

Does Optional remove NullPointerException from Java?

No. null remains a legal value of every reference type, an Optional variable can itself be null and then throws on the first call, and Optional.of(null) throws immediately by design. What Optional does is move the possibility of absence into a method's return type, so the compiler forces the caller to handle it. That eliminates one specific class of bug — forgetting that a lookup can miss — and nothing else.

What is the difference between orElse and orElseGet?

orElse takes a value, so its argument is evaluated before the call runs, whether or not the Optional is present. orElseGet takes a Supplier and calls it only when the Optional is empty. In a four-iteration loop where only one iteration needed a default, the orElse version hit the fallback lookup four times and the orElseGet version hit it once. Use orElse for constants and orElseGet for anything that computes, allocates or has a side effect.

When should I use flatMap instead of map?

Exactly when the mapper itself returns an Optional. map wraps whatever the mapper produced, so an Optional-returning getter passed to map gives you Optional[Optional[...]]; flatMap hands the inner one back unwrapped. Since a well-designed model exposes Optional-returning getters, a chain that walks nested objects is usually a run of flatMap calls ending in a single map. Choosing wrong does not compile, so the compiler is doing this check for you.

Is it wrong to call get() on an Optional?

get() is not deprecated in Java 21 and produces no compiler warning, but there is no reason to prefer it. Since Java 10 orElseThrow() is the same method under a clearer name, and both throw java.util.NoSuchElementException: No value present when empty. A get() guarded by isPresent() is a null check with extra syntax and an extra allocation — the same branch, the same risk, none of the benefit. Use map/orElse for a value, or orElseThrow(supplier) when absence really is an error and you want the message to name the lookup.

Can an Optional field be serialized?

No. Serializable.class.isAssignableFrom(Optional.class) returns falseOptional implements no interfaces at all — and writing an object with an Optional field through ObjectOutputStream fails at run time with java.io.NotSerializableException: java.util.Optional. The fix is to keep the field nullable and return Optional.ofNullable(field) from the accessor; the same object then serialises cleanly while callers still see the absence in the signature.

Should a method ever return null when its type is Optional?

Never. It is the one usage that is strictly worse than not using Optional, because the caller trusts the signature and chains on the result. The JVM message is explicit about what happened — Cannot invoke "java.util.Optional.orElse(Object)" because the return value of "Misuse.findNickname(String)" is null — but by then you have paid for the wrapper and kept the bug. An Optional-returning method has exactly two legal returns: a wrapped value, or Optional.empty().

Why does Map.get still return null instead of an Optional?

Because it cannot change. Map has been in the JDK since Java 1.2 and Map.get is specified to return null for a missing key. Changing the return type would break every existing caller at source level, break binary compatibility because the return type is part of the method descriptor in the class file, and break every third-party class that implements Map. It would also collide with HashMap permitting null values, which is why getOrDefault and containsKey exist. Wrap it yourself at the call site with Optional.ofNullable(map.get(key)) when you want the chain.

Is Optional.of(null) the same as Optional.ofNullable(null)?

No, and the difference is intentional. Optional.of(null) calls Objects.requireNonNull and throws NullPointerException at that line, with java.util.Optional.of(Optional.java:113) in the trace. Optional.ofNullable(null) returns the shared empty instance. Use of as an assertion that the value exists, so a wrong value fails where it was produced; use ofNullable when you are deliberately wrapping something that may legitimately be absent, such as a Map lookup or a legacy getter.

Conclusion

Optional is a single-slot container with two states and one job: to let a return type admit that it might have nothing to give back. Everything good about it follows from that job — the compiler forcing the caller to handle the miss, a chain of flatMap calls collapsing four null checks into one expression, mappers that never see a null because they never run on the empty path. Everything bad about it follows from using it somewhere else — a field that will not serialise, a parameter with three cases instead of two, a map with two kinds of nothing, and the return null; that keeps the bug and adds the wrapper. Learn orElse versus orElseGet properly, because that one is invisible in review and it hit the default loader four times instead of once in the loop above.

That closes Part 3 of this course. Part 4 turns to multithreading, starting from the beginning: what a thread actually is, what the JVM gives each one, and how to create one with Thread and with Runnable.

Related Posts

[Advanced Java] Advanced Enums in Java: Constructors, Constant Bodies, EnumMap and the Enum Singleton

Advanced enums in Java on OpenJDK 21: what javap shows an enum actually compiles to, fields and the implicitly private constructor, constant-specific class bodies and the extra class files they emit, abstract methods, enums implementing interfaces, EnumMap and EnumSet, exhaustive switch, the enum singleton reflection refuses to break, an enum state machine, and the ordinal and values() traps.

[Advanced Java] Generics in Java: Type Parameters, Bounded Types, Wildcards and Type Erasure

Generics in Java on OpenJDK 21: the pre-generics Object container and the ClassCastException it produced, writing generic classes and generic methods, bounded and multiply-bounded type parameters, wildcards and PECS with the exact javac errors, type erasure proved with javap, the Signature attribute, bridge methods, and what SuppressWarnings unchecked really promises.

[Advanced Java] Functional Interfaces: Supplier, Consumer, Function and Predicate

Functional interfaces in java.util.function on OpenJDK 21: the shape grid behind all 43 of them, what @FunctionalInterface really checks, why an abstract equals does not break single-abstract-method status, andThen versus compose, the Predicate and Consumer combinators, the primitive specialisations and the boxing they remove, and how to write your own.

[Advanced Java] The Java Stream API: map, filter, reduce and collect

The Java Stream API on OpenJDK 21: the source-intermediate-terminal pipeline, laziness proved with an interleaved println trace, map, filter, all three reduce overloads, collect and the Collectors factory, primitive streams and the allocation cost of boxing, and the traps around peek, findAny, stateful lambdas and parallelStream.