Lesson 14 of 71
Optional, Null Handling, and API Design
Optional<T> (Java 8) is a container that holds one value or nothing. It is designed as a return type that tells the caller "there may be no result". This lesson covers Optional, the other ways to handle null, and the API design rules that make classes hard to misuse: static factories, builders, fluent APIs, small visibility, immutability, and argument checks.
| Topic | API |
|---|---|
| Creating | Optional.of, ofNullable, empty |
| Transforming | map, flatMap, filter |
| Extracting | orElse, orElseGet, orElseThrow, ifPresentOrElse |
| Argument checks | Objects.requireNonNull, requireNonNullElse, Objects.checkIndex |
| Design | static factories, builders, fluent APIs, visibility, immutability |
Example 1: creating and reading an Optional
Optional.of(v) requires a non-null value. Optional.ofNullable(v) returns an empty Optional when v is null. Optional.empty() is the empty instance.
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
public class OptionalBasics {
static final Map<String, Long> PRICES = new HashMap<>();
static Optional<Long> findPrice(String symbol) {
return Optional.ofNullable(PRICES.get(symbol));
}
public static void main(String[] args) {
PRICES.put("AAPL", 18_950L);
Optional<Long> found = findPrice("AAPL");
Optional<Long> missing = findPrice("XYZ");
System.out.println(found + " " + missing);
System.out.println(found.isPresent() + " " + missing.isEmpty());
System.out.println(found.get() + " " + missing.orElse(0L));
try {
Optional.of(null);
} catch (NullPointerException e) {
System.out.println("Optional.of(null): NullPointerException");
}
try {
missing.get();
} catch (java.util.NoSuchElementException e) {
System.out.println("get on empty: " + e.getMessage());
}
}
}Optional[18950] Optional.empty
true true
18950 0
Optional.of(null): NullPointerException
get on empty: No value presentget() on an empty Optional throws NoSuchElementException, so calling get() without a check moves the null problem instead of solving it. orElseThrow() (Java 10) does the same as get() with a name that states the risk. isEmpty() was added in Java 11.
Example 2: map, flatMap, and filter
map, flatMap, and filter take a function as an argument. A lambda is a short way to write that function: p -> p * 2 means "given p, return p * 2". The syntax is covered in lambdas and functional interfaces; here each lambda is one expression.
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
public class OptionalTransform {
record Account(String id, String managerId) {}
record Manager(String id, String email) {}
static final Map<String, Account> ACCOUNTS = new HashMap<>();
static final Map<String, Manager> MANAGERS = new HashMap<>();
static Optional<Account> account(String id) { return Optional.ofNullable(ACCOUNTS.get(id)); }
static Optional<Manager> manager(String id) { return Optional.ofNullable(MANAGERS.get(id)); }
static String managerEmail(String accountId) {
return account(accountId)
.flatMap(a -> manager(a.managerId()))
.map(m -> m.email())
.filter(e -> e.endsWith("@acme.com"))
.orElse("desk@acme.com");
}
public static void main(String[] args) {
ACCOUNTS.put("A1", new Account("A1", "M1"));
ACCOUNTS.put("A2", new Account("A2", "M9"));
ACCOUNTS.put("A3", new Account("A3", "M2"));
MANAGERS.put("M1", new Manager("M1", "ana@acme.com"));
MANAGERS.put("M2", new Manager("M2", "bob@gmail.com"));
for (String id : new String[] {"A1", "A2", "A3", "A4"}) {
System.out.println(id + " -> " + managerEmail(id));
}
Optional<Optional<Manager>> nested = account("A1").map(a -> manager(a.managerId()));
System.out.println("map gives nested: " + nested);
}
}A1 -> ana@acme.com
A2 -> desk@acme.com
A3 -> desk@acme.com
A4 -> desk@acme.com
map gives nested: Optional[Optional[Manager[id=M1, email=ana@acme.com]]]| Method | Function returns | Result |
|---|---|---|
map(f) |
a plain value U |
Optional<U>, empty if f returns null |
flatMap(f) |
an Optional<U> |
that Optional<U>, not wrapped again |
filter(p) |
boolean |
the same Optional or empty |
Each step runs only when a value is present. A chain replaces nested if (x != null) checks. Using map where the function already returns an Optional gives Optional<Optional<Manager>>, as the last line shows.
Example 3: orElse vs orElseGet, orElseThrow, and ifPresentOrElse
orElse(x) takes a value, so x is computed before the call even when the Optional holds a value. orElseGet(supplier) runs the supplier only when the Optional is empty. A supplier lambda with no arguments is written () -> expression.
import java.util.Optional;
public class OptionalExtract {
static int loads = 0;
static long loadDefault() {
loads++;
return 100L;
}
public static void main(String[] args) {
Optional<Long> present = Optional.of(5L);
Optional<Long> empty = Optional.empty();
present.orElse(loadDefault());
System.out.println("orElse on present, loads = " + loads);
present.orElseGet(() -> loadDefault());
System.out.println("orElseGet on present, loads = " + loads);
System.out.println("orElseGet on empty = " + empty.orElseGet(() -> loadDefault()) + ", loads = " + loads);
try {
empty.orElseThrow(() -> new IllegalStateException("no limit configured"));
} catch (IllegalStateException e) {
System.out.println("orElseThrow: " + e.getMessage());
}
present.ifPresentOrElse(v -> System.out.println("value " + v),
() -> System.out.println("none"));
empty.ifPresentOrElse(v -> System.out.println("value " + v),
() -> System.out.println("none"));
System.out.println(empty.or(() -> Optional.of(7L)).get());
}
}orElse on present, loads = 1
orElseGet on present, loads = 1
orElseGet on empty = 100, loads = 2
orElseThrow: no limit configured
value 5
none
7orElse ran loadDefault although the value was present. When the default is a constant this costs nothing; when it reads a database or allocates, orElseGet is the correct choice. ifPresentOrElse and or were added in Java 9.
Example 4: where Optional does not belong
Optional is meant for return values. The table gives the reason for each case where it is not used, and the program shows the alternatives.
| Use | Problem | Alternative |
|---|---|---|
| Field | an extra object per field; Optional is not Serializable |
nullable private field, Optional from the getter |
| Parameter | callers must wrap arguments, and can still pass null for the Optional |
overloads, or a nullable parameter documented as such |
| Collection or array result | two ways to say "nothing" (empty and an empty list) |
return an empty collection |
Optional<Integer> on hot paths |
boxing plus the wrapper | OptionalInt, OptionalLong, OptionalDouble |
| Map value or collection element | same as fields | leave the entry out |
import java.io.Serializable;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.OptionalLong;
public class OptionalMisuse {
static class Order {
private final String id;
private final String parentId;
Order(String id, String parentId) { this.id = id; this.parentId = parentId; }
Optional<String> parentId() { return Optional.ofNullable(parentId); }
}
static List<String> fillsFor(String orderId) {
return orderId.equals("O1") ? List.of("F1", "F2") : List.of();
}
static OptionalLong bestBid(long[] bids) {
if (bids.length == 0) return OptionalLong.empty();
long best = bids[0];
for (long b : bids) best = Math.max(best, b);
return OptionalLong.of(best);
}
public static void main(String[] args) {
Order child = new Order("O2", "O1");
Order root = new Order("O1", null);
System.out.println(child.parentId() + " " + root.parentId());
System.out.println(fillsFor("O1") + " " + fillsFor("O9").size());
System.out.println(bestBid(new long[] {101, 103, 102}) + " " + bestBid(new long[0]));
System.out.println("Optional serializable: " + Serializable.class.isAssignableFrom(Optional.class));
List<String> ids = new ArrayList<>();
for (String id : new String[] {"O1", "O2"}) {
if (fillsFor(id).isEmpty()) ids.add(id);
}
System.out.println("orders without fills: " + ids);
}
}Optional[O1] Optional.empty
[F1, F2] 0
OptionalLong[103] OptionalLong.empty
Optional serializable: false
orders without fills: [O2]The field parentId stays nullable inside the class, and the getter converts it once at the boundary. An empty list lets callers loop without a check.
Example 5: null-handling strategies and argument checks
Four strategies cover most code: reject null at the boundary, replace it with a default, use a null object (an instance with neutral behavior), or return Optional. java.util.Objects provides the checks. Fail fast means a bad argument throws in the method that receives it, with a message naming the argument, instead of later in unrelated code.
import java.util.List;
import java.util.Objects;
public class NullStrategies {
interface Listener { void onFill(String orderId); }
static final Listener NO_OP = new Listener() {
public void onFill(String orderId) {}
};
static class OrderBook {
private final String symbol;
private final Listener listener;
private final List<Long> levels;
OrderBook(String symbol, Listener listener, List<Long> levels) {
this.symbol = Objects.requireNonNull(symbol, "symbol");
this.listener = Objects.requireNonNullElse(listener, NO_OP);
this.levels = List.copyOf(levels);
}
long level(int index) {
Objects.checkIndex(index, levels.size());
return levels.get(index);
}
void fill(String orderId) { listener.onFill(orderId); }
}
public static void main(String[] args) {
OrderBook book = new OrderBook("AAPL", null, List.of(18_950L, 18_949L));
book.fill("O1");
System.out.println("level 1 = " + book.level(1));
try {
book.level(2);
} catch (IndexOutOfBoundsException e) {
System.out.println(e.getMessage());
}
try {
new OrderBook(null, NO_OP, List.of());
} catch (NullPointerException e) {
System.out.println("NullPointerException: " + e.getMessage());
}
System.out.println(Objects.toString(null, "n/a") + " " + Objects.equals(null, null));
}
}level 1 = 18949
Index 2 out of bounds for length 2
NullPointerException: symbol
n/a truerequireNonNull returns its argument, so it fits in an assignment. requireNonNullElse (Java 9) replaces null with a default. The null-object listener removes every if (listener != null) from the class. checkIndex (Java 9) throws IndexOutOfBoundsException with the index and length in the message. List.copyOf also rejects null elements, so the list is checked too.
Example 6: static factory methods
A static factory method is a static method that returns an instance, used instead of or beside a public constructor. Compared with constructors, it has a name, may return a cached instance, and may return a subtype.
import java.util.HashMap;
import java.util.Map;
public class StaticFactories {
static final class Symbol {
private static final Map<String, Symbol> CACHE = new HashMap<>();
private final String code;
private Symbol(String code) { this.code = code; }
static Symbol of(String code) {
String key = code.strip().toUpperCase();
Symbol s = CACHE.get(key);
if (s == null) {
s = new Symbol(key);
CACHE.put(key, s);
}
return s;
}
@Override public String toString() { return code; }
}
sealed interface Quantity permits Lots, Shares {
static Quantity ofShares(long n) { return new Shares(n); }
static Quantity ofLots(long lots) { return new Lots(lots); }
long shares();
}
record Shares(long n) implements Quantity { public long shares() { return n; } }
record Lots(long lots) implements Quantity { public long shares() { return lots * 100; } }
public static void main(String[] args) {
Symbol a = Symbol.of("aapl");
Symbol b = Symbol.of(" AAPL ");
System.out.println(a + " same instance: " + (a == b));
Quantity q1 = Quantity.ofShares(250);
Quantity q2 = Quantity.ofLots(3);
System.out.println(q1 + " -> " + q1.shares());
System.out.println(q2 + " -> " + q2.shares());
System.out.println(Integer.valueOf(127) == Integer.valueOf(127));
}
}AAPL same instance: true
Shares[n=250] -> 250
Lots[lots=3] -> 300
trueThe private constructor forces every caller through Symbol.of, which normalizes input and returns one instance per code. Quantity.ofLots and ofShares take the same parameter type, which two constructors cannot do. JDK naming conventions: of and valueOf for conversions, from for converting another type, newX/getX when the method lives in a different class (Files.newBufferedReader). The HashMap cache is not thread-safe; concurrent collections cover the thread-safe version.
Example 7: builders and fluent APIs
A builder collects many optional parameters through named methods and creates the object in one build() call that validates everything at once. A fluent API returns an object from each method so calls chain. The builder below is a nested static class that produces an immutable record.
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
public class BuilderExample {
enum Side { BUY, SELL }
enum TimeInForce { DAY, IOC, GTC }
record Order(String symbol, Side side, long quantity, long limitCents,
TimeInForce tif, List<String> tags) {
Order {
tags = List.copyOf(tags);
}
static Builder builder(String symbol, Side side) { return new Builder(symbol, side); }
static final class Builder {
private final String symbol;
private final Side side;
private long quantity;
private long limitCents;
private TimeInForce tif = TimeInForce.DAY;
private final List<String> tags = new ArrayList<>();
private Builder(String symbol, Side side) {
this.symbol = Objects.requireNonNull(symbol, "symbol");
this.side = Objects.requireNonNull(side, "side");
}
Builder quantity(long q) { this.quantity = q; return this; }
Builder limit(long cents) { this.limitCents = cents; return this; }
Builder timeInForce(TimeInForce t) { this.tif = Objects.requireNonNull(t); return this; }
Builder tag(String t) { tags.add(t); return this; }
Order build() {
if (quantity <= 0) throw new IllegalStateException("quantity must be > 0, was " + quantity);
if (limitCents < 0) throw new IllegalStateException("limit must be >= 0");
return new Order(symbol, side, quantity, limitCents, tif, tags);
}
}
}
public static void main(String[] args) {
Order order = Order.builder("AAPL", Side.BUY)
.quantity(100)
.limit(18_950)
.timeInForce(TimeInForce.IOC)
.tag("algo")
.build();
System.out.println(order);
Order.Builder reused = Order.builder("MSFT", Side.SELL).quantity(5);
Order first = reused.build();
reused.tag("late");
System.out.println(first.tags() + " " + reused.build().tags());
try {
Order.builder("MSFT", Side.SELL).build();
} catch (IllegalStateException e) {
System.out.println(e.getMessage());
}
}
}Order[symbol=AAPL, side=BUY, quantity=100, limitCents=18950, tif=IOC, tags=[algo]]
[] [late]
quantity must be > 0, was 0Required values go to the builder's constructor, so they cannot be forgotten; optional values have defaults. The compact constructor copies tags, so changing the builder after build() does not change orders already built. A builder fits when a type has more than about four parameters or several of the same type, where positional arguments can be swapped by mistake.
Example 8: minimal visibility and an immutable API
This example combines the rules into one small module of a trading system. Only the public surface is the interface RiskLimits and its factory; the implementation is a private nested class. Minimizing visibility means each member gets the smallest access level that works, so it can change without breaking callers.
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
public class RiskApi {
public interface RiskLimits {
Optional<Long> limitFor(String account);
RiskLimits withLimit(String account, long maxNotional);
boolean allows(String account, long notional);
static RiskLimits empty() { return Impl.EMPTY; }
}
private static final class Impl implements RiskLimits {
static final Impl EMPTY = new Impl(Map.of());
private final Map<String, Long> limits;
private Impl(Map<String, Long> limits) { this.limits = Map.copyOf(limits); }
@Override public Optional<Long> limitFor(String account) {
return Optional.ofNullable(limits.get(Objects.requireNonNull(account, "account")));
}
@Override public RiskLimits withLimit(String account, long maxNotional) {
Objects.requireNonNull(account, "account");
if (maxNotional <= 0) throw new IllegalArgumentException("maxNotional must be > 0");
Map<String, Long> next = new HashMap<>(limits);
next.put(account, maxNotional);
return new Impl(next);
}
@Override public boolean allows(String account, long notional) {
return limitFor(account).map(max -> notional <= max).orElse(false);
}
}
public static void main(String[] args) {
RiskLimits base = RiskLimits.empty();
RiskLimits limits = base.withLimit("ACC1", 1_000_000).withLimit("ACC2", 50_000);
System.out.println(base.limitFor("ACC1") + " " + limits.limitFor("ACC1"));
System.out.println(limits.allows("ACC1", 900_000) + " " + limits.allows("ACC2", 60_000));
System.out.println("unknown account allowed: " + limits.allows("ACC3", 1));
try {
limits.withLimit("ACC3", 0);
} catch (IllegalArgumentException e) {
System.out.println(e.getMessage());
}
}
}Optional.empty Optional[1000000]
true false
unknown account allowed: false
maxNotional must be > 0| Rule | Where it appears |
|---|---|
| Static factory | RiskLimits.empty() hides the implementation class |
| Minimal visibility | Impl and its constructor are private |
| Immutability | withLimit returns a new object; Map.copyOf prevents outside changes |
| Optional return | limitFor says that an account may have no limit |
| Fail fast | requireNonNull and the range check run before any state changes |
| Safe default | an unknown account is rejected (orElse(false)) |
Because base never changes, it can be shared between threads without locks, and old versions stay valid for audit.
Interview points
Optionalis for return types; avoid it in fields, parameters, collections, and as a wrapper for collections.orElse(x)evaluatesxalways;orElseGetevaluates lazily.mapwraps the function result;flatMapexpects the function to return anOptional.Optional.of(null)throws;ofNullablereturns empty;mapreturningnullgives empty.get()andorElseThrow()throwNoSuchElementExceptionon empty.- Primitive variants
OptionalInt,OptionalLong,OptionalDoubleavoid boxing. Objects.requireNonNull,requireNonNullElse, andcheckIndeximplement fail-fast checks at API boundaries.- Static factories have names, can cache, and can return subtypes; private constructors force their use.
- Builders validate in
build(), take required values in their constructor, and copy mutable inputs. - Immutable objects with
withXmethods are thread-safe without locks.
Summary
Optionalmakes "no result" explicit in a method's return type.map,flatMap, andfilterchain steps that run only when a value is present.nullis handled by rejecting it, defaulting it, a null object, orOptional.- Static factories, builders, and fluent methods shape how callers create objects.
- Small visibility and immutability let an implementation change without breaking callers.
