Part 9 of 16

Optional: Eliminating NullPointerException the Right Way

The Problem Optional Solves

NullPointerException is the most common runtime exception in Java. The root cause is that null is used for two different things simultaneously:

  1. “This field has no value” (intentional absence)
  2. “This reference was never set” (programming error)

Callers can’t tell which meaning applies without reading the documentation or source code. And nothing in the type system forces them to check.

// Java 7: what does null mean here?
public User findById(Long id) {
    // returns User, or null if not found
}

// Caller can easily forget the null check
User user = repo.findById(42L);
System.out.println(user.getName()); // NullPointerException if not found

Optional<T> makes the possibility of absence explicit in the type signature:

// Java 8: the return type tells you "might not be present"
public Optional<User> findById(Long id) { ... }

// Caller cannot "forget" — they must handle the Optional
Optional<User> user = repo.findById(42L);
System.out.println(user.map(User::getName).orElse("unknown"));

Creating Optional Values

// Empty optional
Optional<String> empty = Optional.empty();

// Optional with a non-null value — throws NullPointerException if value is null
Optional<String> present = Optional.of("Hello");

// Optional that may contain null — use this when you're not sure
Optional<String> nullable = Optional.ofNullable(someStringThatMightBeNull);

Rule: Use Optional.of() only when you know the value is non-null. Use Optional.ofNullable() at system boundaries where null might arrive from external sources.


Checking and Extracting Values

isPresent / isEmpty (Java 11+)

Optional<String> opt = Optional.of("Hello");

if (opt.isPresent()) {
    System.out.println(opt.get()); // "Hello"
}

isPresent() + get() is the worst way to use Optional — it’s just a verbose null check. Prefer the transformation methods below.

get()

String value = opt.get(); // throws NoSuchElementException if empty

Never call get() without first checking isPresent(). But if you’re doing that, you might as well use orElseThrow for clearer intent.


Extracting with Fallbacks

orElse — provide a default value

String name = opt.orElse("unknown");

The default value is always evaluated, even if the Optional is present. This is rarely a problem for literals but can be wasteful for expensive operations:

// createDefaultUser() is ALWAYS called, even when opt is present
User user = userOpt.orElse(createDefaultUser()); // potentially wasteful

orElseGet — provide a default via Supplier (lazy)

// createDefaultUser() only called when optional is empty
User user = userOpt.orElseGet(() -> createDefaultUser());

// Method reference form
User user = userOpt.orElseGet(UserFactory::createDefault);

Rule: Always prefer orElseGet over orElse when the fallback is an object construction, a method call, or anything non-trivial.

orElseThrow — throw if empty

// Java 8: throws NoSuchElementException by default
User user = userOpt.orElseThrow();

// Custom exception
User user = userOpt.orElseThrow(() -> new UserNotFoundException(id));

Use orElseThrow at the boundary where absence is a programming error or a domain exception, not a normal case.


Transforming Optional Values

map — transform the value if present

Optional<String> name = userOpt.map(User::getName);
Optional<Integer> len  = name.map(String::length);

// Chain
Optional<String> city = userOpt
    .map(User::getAddress)
    .map(Address::getCity);

map applies the function to the value and wraps the result in a new Optional. If the original Optional is empty, map returns empty without calling the function.

flatMap — transform when the function returns Optional

// User.getAddress() returns Optional<Address>
// Address.getCity() returns Optional<String>

// Without flatMap — gets Optional<Optional<String>>
Optional<Optional<String>> wrong = userOpt.map(u -> u.getAddress());

// With flatMap — flattens to Optional<String>
Optional<String> city = userOpt
    .flatMap(User::getAddress)       // Optional<Address>
    .flatMap(Address::getCity);      // Optional<String>

Use flatMap whenever the mapping function itself returns an Optional.

filter — keep value only if it passes a predicate

Optional<String> longName = nameOpt.filter(s -> s.length() > 5);
// If nameOpt is present but name.length() <= 5, returns empty
// If nameOpt is empty, returns empty

Consuming Values

ifPresent — execute a Consumer if value is present

userOpt.ifPresent(user -> System.out.println("Found: " + user.getName()));

ifPresentOrElse (Java 9+)

// Not available in Java 8, but worth knowing for reference
userOpt.ifPresentOrElse(
    user -> System.out.println("Found: " + user.getName()),
    ()   -> System.out.println("Not found")
);

In Java 8, handle both sides explicitly:

if (userOpt.isPresent()) {
    System.out.println("Found: " + userOpt.get().getName());
} else {
    System.out.println("Not found");
}

Optional in Stream Pipelines

Filter out empty optionals and unwrap present ones:

List<Optional<User>> optionals = ids.stream()
    .map(repo::findById)
    .collect(Collectors.toList());

// Java 8: filter present, then map to value
List<User> users = optionals.stream()
    .filter(Optional::isPresent)
    .map(Optional::get)
    .collect(Collectors.toList());

// Java 9+: Optional.stream() — cleaner
List<User> users = optionals.stream()
    .flatMap(Optional::stream)
    .collect(Collectors.toList());

Anti-Patterns

Anti-pattern 1: Optional as a field type

// WRONG
public class User {
    private Optional<String> middleName; // Don't do this
}

// RIGHT: use null for optional fields; Optional is for return types
public class User {
    private String middleName; // nullable

    public Optional<String> getMiddleName() {
        return Optional.ofNullable(middleName);
    }
}

Optional is not Serializable, takes more memory than a null reference, and was explicitly designed for method return types, not fields.

Anti-pattern 2: Optional as a method parameter

// WRONG: forces callers to wrap values in Optional for no benefit
public void process(Optional<String> name) { ... }

// RIGHT: use overloading or @Nullable
public void process(String name) { ... }
public void process() { process(null); }

Anti-pattern 3: isPresent() + get()

// WRONG: verbose null check
if (opt.isPresent()) {
    return opt.get().getName();
}
return "unknown";

// RIGHT: map + orElse
return opt.map(User::getName).orElse("unknown");

Anti-pattern 4: Optional.of() with a potentially null value

// WRONG: throws NullPointerException if value is null
Optional<String> opt = Optional.of(potentiallyNullValue);

// RIGHT
Optional<String> opt = Optional.ofNullable(potentiallyNullValue);

Anti-pattern 5: Wrapping values that will never be empty

// WRONG: adds overhead with no benefit
public Optional<List<Order>> getOrders() {
    return Optional.of(this.orders); // orders is always initialised
}

// RIGHT: return empty list (null object pattern) or plain reference
public List<Order> getOrders() {
    return Collections.unmodifiableList(this.orders);
}

When to Use Optional

SituationUse Optional?
Method return type where “not found” is validYes
Field in a classNo — use nullable field + Optional getter
Method parameterNo — use overloading or null
Collection elementNo — filter out nulls instead
Performance-critical hot pathNo — Optional allocates an object
Replacing every null in legacy codeNo — only at API boundaries

Practical Examples

Repository layer

public interface UserRepository {
    Optional<User> findById(Long id);
    Optional<User> findByEmail(String email);
    List<User> findAll(); // never absent — return empty list, not Optional<List>
}

Service layer

public String getUserCity(Long userId) {
    return userRepository.findById(userId)
        .flatMap(User::getAddress)
        .map(Address::getCity)
        .orElse("Unknown");
}

public User getOrCreateUser(String email) {
    return userRepository.findByEmail(email)
        .orElseGet(() -> createNewUser(email));
}

Chaining fallbacks

// Try primary source, then cache, then default
String config = primaryConfig()
    .or(() -> cachedConfig())     // Java 9+
    .orElseGet(() -> defaultConfig());

// Java 8 equivalent:
Optional<String> cfg = primaryConfig();
if (!cfg.isPresent()) cfg = cachedConfig();
String config = cfg.orElseGet(() -> defaultConfig());

Summary

MethodBehaviour
Optional.of(v)Wraps non-null value; throws NPE on null
Optional.ofNullable(v)Wraps value; returns empty if null
Optional.empty()Empty optional
isPresent()True if value present
get()Returns value; throws if empty
orElse(default)Returns value or default (always evaluated)
orElseGet(supplier)Returns value or supplier result (lazy)
orElseThrow(supplier)Returns value or throws exception
map(f)Transforms value; returns empty if absent
flatMap(f)Transforms with Optional-returning function
filter(p)Keeps value only if predicate passes
ifPresent(consumer)Runs consumer if value present

Next Step

Date and Time API (JSR-310): LocalDate, ZonedDateTime, Duration, Period →

Part of the DevOps Monk Java tutorial series: Java 8Java 11Java 17Java 21