On This Page
What you'll build: a phone lookup that can't throw
Here's the output of the finished program. Four tickets go in: one with a phone number, one whose agent has no number, one nobody owns, and one that doesn't exist. The old null-check code and the new Optional code run side by side.
ticket 1 legacy=555-0101 optional=Optional[555-0101] call=555-0101
ticket 2 legacy=null optional=Optional.empty call=555-9999
ticket 3 legacy=null optional=Optional.empty call=555-9999
ticket 99 legacy=null optional=Optional.empty call=555-9999
on-call supplier ran 3 times for 4 lookups
That last line is the point of half this article. Four lookups, and the fallback ran three times, not four. Get the orElse / orElseGet choice wrong and it runs four times. For a phone number that's harmless. For a database call or a remote service it isn't.
This is how to use Optional in Java the way the JDK authors meant it: as a return type that says "there might be nothing here", and not as a wrapper you sprinkle everywhere. You'll build a small support-ticket service, replace a pyramid of if (x != null) with one chain, and then break things on purpose so you know the traps by sight.
TicketRepository.findById(id) --> Optional<Ticket>
| flatMap(Ticket::assignedAgent) --> Optional<Agent>
| flatMap(Agent::phone) --> Optional<String>
v
orElseGet(onCallPhone) / orElseThrow(...) / ifPresentOrElse(...)
This is for you if you know Java and have seen Optional in other people's code, but you've never been sure when get() is acceptable (almost never) or what orElse really does.
What you need
| Tool | Version I used | Check it with |
|---|---|---|
| JDK | OpenJDK 21.0.12.1 | java -version |
| Build | plain javac (Maven pom.xml below, not executed) |
javac -version |
| IntelliJ IDEA (optional) | 2026.2 line | Help → About |
| Eclipse IDE (optional) | current release package "for Java Developers" | Help → About Eclipse IDE |
You need Java 17 or newer for the code as written, because it uses records (Java 16). Everything in Optional itself works back to Java 8, apart from the methods I flag in the version notes. If you don't have a JDK, install one with SDKMAN (sdk install java 21.0.x-tem, pick the exact id from sdk list java) or from your OS package manager.
No Docker or Podman this time. Nothing here needs a database or a container, so I left them out.
Spin up the project
I used the plain command line for this one, because there's nothing to download and the checks run in under a second. IDE steps follow.
mkdir tickets && cd tickets
mkdir -p src/main/java/com/example/tickets src/test/java/com/example/tickets
The tree you'll end up with:
tickets/
├── pom.xml (only if you use Maven)
└── src/
├── main/java/com/example/tickets/
│ ├── Agent.java
│ ├── Main.java
│ ├── Ticket.java
│ ├── TicketRepository.java
│ └── TicketService.java
└── test/java/com/example/tickets/
└── TicketChecks.java
In IntelliJ IDEA
Choose File → New → Project, pick Java on the left, set the build system to IntelliJ (or Maven), and select JDK 21. Name it tickets and create it. Right-click src → New → Package and create com.example.tickets. I verified these menu paths from the JetBrains documentation. I didn't click through them.
In Eclipse
Choose File → New → Java Project, name it tickets, and set the execution environment to JavaSE-21. Then right-click src → New → Package. Docs only; not executed.
With Maven
Maven isn't needed to run any of this, and I couldn't download plugins from Maven Central in my environment, so I didn't execute this file. It has no dependencies, and I'd expect it to work as-is:
pom.xml
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.example</groupId>
<artifactId>tickets</artifactId>
<version>1.0.0</version>
<properties>
<maven.compiler.release>21</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
</project>
Wire it up: from null checks to one chain
Step 1: Model the data and say what's optional
Goal: make "this field might be missing" visible in the types.
An Agent always has a name. The phone number might not exist. A Ticket might have no assignee. I'm using records here (see how to use Java records) to keep the data classes short.
src/main/java/com/example/tickets/Agent.java
package com.example.tickets;
import java.util.Optional;
public record Agent(String name, String phoneNumber) {
public Agent {
if (name == null || name.isBlank()) {
throw new IllegalArgumentException("name is required");
}
}
/** The phone number is optional, so the accessor says so in its type. */
public Optional<String> phone() {
return Optional.ofNullable(phoneNumber).map(String::strip).filter(s -> !s.isEmpty());
}
}
src/main/java/com/example/tickets/Ticket.java
package com.example.tickets;
import java.util.Optional;
public record Ticket(long id, String subject, Agent assignee) {
public Optional<Agent> assignedAgent() {
return Optional.ofNullable(assignee);
}
}
Why: the record components stay plain String and Agent (nullable), and the accessor-style methods phone() and assignedAgent() hand back Optional. I'd rather not make the component itself an Optional; more on that in the traps. The phone() method also strips whitespace and treats a blank string as missing, so every caller gets the same answer.
Checkpoint: compile from the project root.
mkdir -p out
javac --release 21 -d out $(find src -name '*.java')
Nothing printed means success. If you add the other files first and compile early, you'll get cannot find symbol errors until TicketService exists; that's expected.
Step 2: Return Optional from the lookup
Goal: stop returning null from findById.
src/main/java/com/example/tickets/TicketRepository.java
package com.example.tickets;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
public class TicketRepository {
private final Map<Long, Ticket> store = new HashMap<>();
public void save(Ticket ticket) {
store.put(ticket.id(), ticket);
}
/** "Might not exist" is part of the signature, not a comment. */
public Optional<Ticket> findById(long id) {
return Optional.ofNullable(store.get(id));
}
}
Why: Optional.ofNullable is the bridge between old null-returning APIs (here, Map.get) and your new signature. Use of only when you know the value isn't null; it throws on null, which we'll trigger later.
Step 3: Replace the null-check pyramid with a chain
Goal: write the lookup twice, once the old way and once with Optional, so you can compare them.
src/main/java/com/example/tickets/TicketService.java
package com.example.tickets;
import java.util.List;
import java.util.Optional;
import java.util.function.Supplier;
public class TicketService {
private final TicketRepository repo;
private final Supplier<String> onCallPhone;
public TicketService(TicketRepository repo, Supplier<String> onCallPhone) {
this.repo = repo;
this.onCallPhone = onCallPhone;
}
/** The null-check version most of us have written (and then regretted). */
public String assigneePhoneLegacy(long id) {
Ticket t = repo.findById(id).orElse(null);
if (t != null) {
Agent a = t.assignee();
if (a != null) {
String p = a.phoneNumber();
if (p != null && !p.isBlank()) {
return p.strip();
}
}
}
return null;
}
/** Same lookup as one chain; the result still says "might be empty". */
public Optional<String> assigneePhone(long id) {
return repo.findById(id)
.flatMap(Ticket::assignedAgent)
.flatMap(Agent::phone);
}
/** Fall back to the on-call phone, computed only when it's needed. */
public String phoneToCall(long id) {
return assigneePhone(id).orElseGet(onCallPhone);
}
/** Callers who can't continue without a ticket get a clear exception. */
public Ticket requireTicket(long id) {
return repo.findById(id)
.orElseThrow(() -> new IllegalArgumentException("No ticket with id " + id));
}
/** Try the assignee's phone, then the team lead's, before giving up. */
public Optional<String> phoneWithFallback(long id, Agent teamLead) {
return assigneePhone(id).or(teamLead::phone);
}
/** Names of everyone who owns at least one of these tickets. */
public List<String> assigneeNames(List<Long> ids) {
return ids.stream()
.map(repo::findById)
.flatMap(Optional::stream)
.flatMap(t -> t.assignedAgent().stream())
.map(Agent::name)
.distinct()
.toList();
}
public String describe(long id) {
return repo.findById(id)
.filter(t -> t.assignee() != null)
.map(t -> "#" + t.id() + " " + t.subject() + " -> " + t.assignee().name())
.orElse("#" + id + " is missing or unassigned");
}
}
Read assigneePhoneLegacy and assigneePhone next to each other. They return the same answers (the tests prove it later), but the second one has no variables, no nesting, and no way to forget a check. The tools in play:
maptransforms the value if one is present. Use it when your function returns a plain value.flatMapismapfor functions that already return anOptional. Usingmapthere gives youOptional<Optional<Agent>>, which compiles and is useless.orElseGet(supplier)computes a fallback only when theOptionalis empty.orElseThrow(supplier)turns "empty" into an exception with a message you chose.or(supplier)(Java 9+) tries anotherOptionalbefore giving up.Optional::stream(Java 9+) turns anOptionalinto a stream of zero or one elements, which is the tidy way to drop the empties from a list.filterempties theOptionalwhen the predicate fails.
Step 4: Add a main method
Goal: see all four cases at once.
src/main/java/com/example/tickets/Main.java
package com.example.tickets;
import java.util.List;
public class Main {
public static void main(String[] args) {
TicketRepository repo = new TicketRepository();
repo.save(new Ticket(1, "Login loop", new Agent("Priya", " 555-0101 ")));
repo.save(new Ticket(2, "Refund missing", new Agent("Marcus", null)));
repo.save(new Ticket(3, "Typo on invoice", null));
int[] onCallCalls = {0};
TicketService svc = new TicketService(repo, () -> { onCallCalls[0]++; return "555-9999"; });
Agent lead = new Agent("Dana", "555-0200");
for (long id : new long[] {1, 2, 3, 99}) {
System.out.printf("ticket %-2d legacy=%-9s optional=%-14s call=%s%n",
id, svc.assigneePhoneLegacy(id), svc.assigneePhone(id), svc.phoneToCall(id));
}
System.out.println("on-call supplier ran " + onCallCalls[0] + " times for 4 lookups");
System.out.println(svc.phoneWithFallback(2, lead));
System.out.println(svc.assigneeNames(List.of(1L, 2L, 3L, 99L, 1L)));
System.out.println(svc.describe(1));
System.out.println(svc.describe(3));
svc.assigneePhone(2).ifPresentOrElse(
p -> System.out.println("calling " + p),
() -> System.out.println("no phone for ticket 2, paging on-call"));
try {
svc.requireTicket(99);
} catch (IllegalArgumentException e) {
System.out.println("caught: " + e.getMessage());
}
}
}
Checkpoint: compile and run.
javac --release 21 -d out $(find src -name '*.java')
java -cp out com.example.tickets.Main
ticket 1 legacy=555-0101 optional=Optional[555-0101] call=555-0101
ticket 2 legacy=null optional=Optional.empty call=555-9999
ticket 3 legacy=null optional=Optional.empty call=555-9999
ticket 99 legacy=null optional=Optional.empty call=555-9999
on-call supplier ran 3 times for 4 lookups
Optional[555-0200]
[Priya, Marcus]
#1 Login loop -> Priya
#3 is missing or unassigned
no phone for ticket 2, paging on-call
caught: No ticket with id 99
If you see that, the hard part's done. Notice ticket 1: the stored number was " 555-0101 " with spaces, and both versions returned it stripped.
Check it works
Walk through the output once, because each line is a rule.
- Line 5, "ran 3 times for 4 lookups":
orElseGetonly called the supplier for tickets 2, 3 and 99. Ticket 1 had a phone, so the fallback never ran. Optional[555-0200]: ticket 2's agent has no number, soor(teamLead::phone)tried the team lead's instead.[Priya, Marcus]: ids 3 and 99 produced nothing, and the repeated id 1 was removed bydistinct(). That'sflatMap(Optional::stream)doing the filtering.#3 is missing or unassigned:filteremptied theOptionalfor the unassigned ticket, andorElsewith a cheap string built the message.
Now prove that orElse(expression) really evaluates its argument eagerly. I'll do it in the tests.
Test it
I couldn't reach Maven Central from my environment to pull JUnit, so these checks are a plain main method that throws on the first failure. Port each check to an @Test with AssertJ's assertThat(...) if you use JUnit 5 (a dedicated article is planned: how to write unit tests with JUnit 5). The logic doesn't change.
src/test/java/com/example/tickets/TicketChecks.java
package com.example.tickets;
import java.util.List;
import java.util.NoSuchElementException;
import java.util.Optional;
public class TicketChecks {
static int passed = 0;
static void check(String name, boolean ok) {
if (!ok) throw new AssertionError("FAILED: " + name);
passed++;
System.out.println("ok " + name);
}
public static void main(String[] args) {
TicketRepository repo = new TicketRepository();
repo.save(new Ticket(1, "Login loop", new Agent("Priya", " 555-0101 ")));
repo.save(new Ticket(2, "Refund missing", new Agent("Marcus", null)));
repo.save(new Ticket(3, "Typo on invoice", null));
repo.save(new Ticket(4, "Blank phone", new Agent("Lee", " ")));
int[] calls = {0};
TicketService svc = new TicketService(repo, () -> { calls[0]++; return "555-9999"; });
Agent lead = new Agent("Dana", "555-0200");
check("phone present and stripped", svc.assigneePhone(1).equals(Optional.of("555-0101")));
check("null phone is empty", svc.assigneePhone(2).isEmpty());
check("unassigned ticket is empty", svc.assigneePhone(3).isEmpty());
check("missing ticket is empty", svc.assigneePhone(99).isEmpty());
check("blank phone is empty", svc.assigneePhone(4).isEmpty());
for (long id : new long[] {1, 2, 3, 4, 99}) {
String legacy = svc.assigneePhoneLegacy(id);
check("legacy matches optional for " + id, Optional.ofNullable(legacy).equals(svc.assigneePhone(id)));
}
calls[0] = 0;
svc.phoneToCall(1);
check("orElseGet does not call supplier when value present", calls[0] == 0);
check("orElseGet falls back", svc.phoneToCall(3).equals("555-9999") && calls[0] == 1);
calls[0] = 0;
svc.assigneePhone(1).orElse(svc.phoneToCall(3));
check("orElse(expr) evaluates expr even when value present", calls[0] == 1);
check("or() uses lead", svc.phoneWithFallback(2, lead).equals(Optional.of("555-0200")));
check("or() keeps assignee", svc.phoneWithFallback(1, lead).equals(Optional.of("555-0101")));
check("distinct names via stream()", svc.assigneeNames(List.of(1L, 2L, 3L, 99L, 1L)).equals(List.of("Priya", "Marcus")));
check("describe unassigned", svc.describe(3).equals("#3 is missing or unassigned"));
try { svc.requireTicket(99); check("requireTicket throws", false); }
catch (IllegalArgumentException e) { check("requireTicket message", e.getMessage().equals("No ticket with id 99")); }
try { Optional.of(null); check("Optional.of(null) throws", false); }
catch (NullPointerException e) { check("Optional.of(null) throws NPE", true); }
try { Optional.empty().get(); check("get() throws", false); }
catch (NoSuchElementException e) { check("get() on empty: " + e.getMessage(), e.getMessage().equals("No value present")); }
check("Optional.equals compares contents", Optional.of("a").equals(Optional.of("a")) && Optional.empty().equals(Optional.empty()));
System.out.println(passed + " checks passed");
}
}
javac --release 21 -d out $(find src -name '*.java')
java -cp out com.example.tickets.TicketChecks
ok phone present and stripped
ok null phone is empty
ok unassigned ticket is empty
ok missing ticket is empty
ok blank phone is empty
ok legacy matches optional for 1
ok legacy matches optional for 2
ok legacy matches optional for 3
ok legacy matches optional for 4
ok legacy matches optional for 99
ok orElseGet does not call supplier when value present
ok orElseGet falls back
ok orElse(expr) evaluates expr even when value present
ok or() uses lead
ok or() keeps assignee
ok distinct names via stream()
ok describe unassigned
ok requireTicket message
ok Optional.of(null) throws NPE
ok get() on empty: No value present
ok Optional.equals compares contents
21 checks passed
The legacy matches optional checks run all five tickets through both implementations. That's the safety net for a refactor: if you're replacing null-check code in a real codebase, write that comparison first.
How it works
Optional<T> is a final class holding either one non-null T or nothing. There's a single shared empty instance, which is why Optional.empty() == Optional.empty() printed true when I tried it. Don't lean on that: two Optional.of("x") calls give you different objects (== printed false), so compare with equals. Optional.equals compares the contents.
map and flatMap look inside the box. If the box is empty, your function never runs, and the empty result flows down the chain. That's why the chain in assigneePhone can't throw a NullPointerException: there's no point where a null is dereferenced. The orElse family is where the chain ends and you decide what "nothing" means: a default, a computed default, or an exception.
The design intent, from the Javadoc: Optional is for return types where "no result" is a normal outcome and null would likely cause errors. It isn't meant for fields, parameters, or collection elements. For the field-and-parameter reasoning in more depth, see how to create an immutable class in Java, where I model optional data without Optional fields. If you want the equals/hashCode side of it, how to implement equals() and hashCode() in Java covers it.
When it doesn't work
These are all things I forced while building this, with the exact output from my run.
Optional.of(null) throws. Use ofNullable when the value might be null.
java.lang.NullPointerException
get() on an empty Optional. The message is No value present, and it's an unchecked NoSuchElementException, so it shows up at runtime and not in review. Replace get() with orElseThrow(...) so the message says what actually went wrong (No ticket with id 99). Since Java 10 there's also a no-argument orElseThrow() that does the same as get() but says so in its name.
The old chain on a null. This is what you're moving away from. On Java 21 the helpful NPE message names the exact call:
NPE: Cannot invoke "Traps$Agent.phone()" because the return value of "Traps$Ticket.a()" is null
Optional<Optional<Agent>>. If you used map where the function returns an Optional, the output looks like Optional[Optional[a]]. Switch to flatMap.
Type errors from mixing the two worlds. These compile errors are common when you're half-migrated:
Bad.java:3: error: incompatible types: String cannot be converted to Optional<String>
static Optional<String> f() { return "x"; }
Wrap the value with Optional.of(...) or ofNullable(...).
Optional as a field in a serialized class. Optional doesn't implement Serializable:
NotSerializable: java.util.Optional
That's by design. Keep the field nullable and expose an Optional getter, the way Ticket.assignedAgent() does. If you hit an InvalidClassException instead, that's a different problem: see java.io.InvalidClassException: local class incompatible.
Compiling for an old Java version. I tried --release 8 and --release 11. Both fail on the records, not on Optional:
Agent.java:5: error: records are not supported in -source 8
If you're stuck on Java 8, replace the records with plain classes. And note which Optional methods you can't use there: or, stream, ifPresentOrElse (Java 9), orElseThrow() with no argument (Java 10), isEmpty (Java 11).
orElse running your expensive code. Not an error, which is why it's the worst one. opt.orElse(loadFromDatabase()) calls loadFromDatabase() every time, even when opt has a value. My check orElse(expr) evaluates expr even when value present proves it. Use orElseGet(() -> loadFromDatabase()). For a constant, a literal, or a variable that's already computed, orElse is fine.
If your real problem is an NPE you can't explain, see java.lang.NullPointerException: cannot invoke because null.
Going further
Habits I'd keep:
- Return
Optional, don't accept it. A method parameter of typeOptional<String>forces the caller to wrap something and still allowsnullto be passed. Use two overloads or a nullable parameter with a clear Javadoc. - Never return
Optional<List<T>>. An empty list already means "nothing". I confirmedOptional.of(List.of())is present and empty at once (true true), which makes callers check twice. - Don't call
isPresent()thenget(). That's a null check with extra steps. Usemap,ifPresent, orifPresentOrElse. - Skip it in hot loops and DTOs. Each
Optionalis an allocation, and JSON libraries handle a nullable field more predictably than anOptionalfield. For primitives there areOptionalInt,OptionalLongandOptionalDouble. - Don't wrap
Optional.ofNullable(x).orElse(y)around a single null check.x != null ? x : yis clearer. UseObjects.requireNonNullElse(x, y)for the same thing without the allocation.
Version notes: Optional arrived in Java 8. or, stream, ifPresent/ifPresentOrElse (Java 9), no-arg orElseThrow() (Java 10), isEmpty (Java 11). Java 21 gives you everything above. I only ran this on 21, so check the Javadoc if you're on a newer JDK.
Next in this series: how to implement the strategy pattern in Java and how to implement the builder pattern in Java. Build time: I built this in one sitting, and the slowest part was working out a clean way to prove that orElse evaluates eagerly (a counter in the supplier did it).
Recap
- Return
Optionalfrom methods where "no result" is normal; useofNullableto wrap legacy null returns. - Chain with
mapfor plain values andflatMapforOptional-returning functions. - End chains with
orElseGet,orElseThrow(...)orifPresentOrElse, never a bareget(). - Use
orElseGetwhenever the fallback costs something. - Don't use
Optionalfor fields, parameters, collections or serialization. - Before refactoring null-check code, write the old-versus-new comparison test.
Optional questions people ask
When should I use Optional in Java?
As a return type for a method that might legitimately find nothing, like a lookup by id. Avoid it for fields, method parameters and collection elements.
What's the difference between orElse and orElseGet?orElse(x) evaluates x before the call, every time. orElseGet(supplier) runs the supplier only when the Optional is empty. In my run the supplier executed 3 times for 4 lookups, where orElse would have executed 4.
Should I ever call Optional.get()?
Rarely. It throws NoSuchElementException: No value present on empty. Prefer orElseThrow(() -> new SomeException("clear message")), or the no-argument orElseThrow() on Java 10+.
Can I use Optional in JPA entities or JSON classes?
Keep the field a plain nullable type and return Optional from the getter. Optional isn't Serializable, and persistence and JSON mapping handle plain nullable fields more predictably.
How do I turn a list of Optionals into a list of values?list.stream().flatMap(Optional::stream).toList() on Java 9+, which is what assigneeNames does.
References
- java.util.Optional, Java SE 21 API
- JEP 395: Records
- JEP 358: Helpful NullPointerExceptions
- IntelliJ IDEA release notes, JetBrains blog (2026.2 line, observed 2026-10-25)