Part 9 of 16

Optional: Eliminating NullPointerException the Right Way

The Problem Optional Solves

NullPointerException is the most common runtime exception in Java, and it is almost always avoidable. Optional<T> is not a magic fix — used incorrectly it becomes a more verbose null check. Used correctly, it encodes the possibility of absence directly in the type system, so callers can never “forget” to handle the empty case. This article covers both how to use Optional and — critically — how not to. 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: What NOT to Do

These are the most common misuses of Optional seen in production codebases. Each one either re-introduces the problem Optional was designed to solve, or adds cost with no benefit.

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

Anti-pattern 6: Using Optional.get() without checking isPresent()

This is the most dangerous misuse — it replaces a NullPointerException with a NoSuchElementException, which is no better:

Optional<User> userOpt = repo.findById(id);
String name = userOpt.get(); // WRONG: throws NoSuchElementException if empty

// RIGHT option 1: supply a default
String name = userOpt.map(User::getName).orElse("unknown");

// RIGHT option 2: throw a meaningful domain exception
User user = userOpt.orElseThrow(() -> new UserNotFoundException(id));

Anti-pattern 7: isPresent() + get() — the verbose null check in disguise

isPresent() followed by get() is the pattern Optional was designed to make unnecessary. It offers no readability benefit over a plain null check and actually requires more characters:

// WRONG: this is just a null check with extra steps
Optional<String> city = findCity(userId);
if (city.isPresent()) {
    System.out.println("City: " + city.get());
}

// RIGHT: express the intent directly
findCity(userId).ifPresent(c -> System.out.println("City: " + c));

// Also WRONG: using isPresent() in a ternary
String result = opt.isPresent() ? opt.get() : "default";

// RIGHT
String result = opt.orElse("default");

Anti-pattern 8: Returning null instead of Optional.empty()

If a method signature declares Optional<T> as its return type, returning null is a contract violation that causes a NullPointerException at the call site — the very error Optional was supposed to prevent:

// WRONG: caller does opt.map(...) and gets NPE because opt itself is null
public Optional<User> findById(Long id) {
    User user = db.query(id);
    return user != null ? Optional.of(user) : null; // never return null from Optional method
}

// RIGHT
public Optional<User> findById(Long id) {
    return Optional.ofNullable(db.query(id));
}

Anti-pattern 9: Using Optional in performance-critical hot paths

Optional.of() allocates a new object on the heap. In code called millions of times per second (tight loops, high-throughput collectors, low-latency systems), this allocation pressure is measurable:

// Fine for normal code paths
Optional<User> user = repo.findById(id);

// WRONG in a hot loop called millions of times per second
for (long id : millionsOfIds) {
    Optional<String> name = Optional.ofNullable(nameCache.get(id)); // unnecessary allocation
    if (name.isPresent()) process(name.get());
}

// RIGHT in a hot path: plain null check is faster
for (long id : millionsOfIds) {
    String name = nameCache.get(id);
    if (name != null) process(name);
}

Use Optional at API boundaries and in service/repository layers. Avoid it inside tight loops or methods that are provably on the hot path.


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 and Service Layers (production pattern)

A well-structured repository returns Optional; the service layer chains transformations without any explicit null checks:

// Repository — signals "might not exist" in the signature
public class CustomerRepository {
    public Optional<Customer> findById(Long id) {
        return Optional.ofNullable(jdbcTemplate.queryForObject(
            "SELECT * FROM customers WHERE id = ?", customerMapper, id));
    }
}

// Service — consumes Optional cleanly
public class CustomerService {
    private final CustomerRepository repo;

    // Safe consumption with ifPresent
    public void sendWelcome(Long customerId) {
        repo.findById(customerId)
            .ifPresent(customer -> emailService.sendWelcome(customer));
    }

    // Transform and provide a default
    public String getEmail(Long customerId) {
        return repo.findById(customerId)
            .map(Customer::getEmail)
            .orElse("no-reply@company.com");
    }

    // Throw domain exception when absence is an error
    public Customer getOrThrow(Long customerId) {
        return repo.findById(customerId)
            .orElseThrow(() -> new CustomerNotFoundException(customerId));
    }

    // Find-or-create pattern
    public Customer findOrCreate(String email) {
        return repo.findByEmail(email)
            .orElseGet(() -> createNewCustomer(email));
    }
}

Payment pipeline with flatMap

Chain through multiple Optional-returning accessors without nested null checks:

// Each accessor returns Optional — no NPE risk anywhere in the chain
public Optional<PaymentResult> processPayment(Long customerId, BigDecimal amount) {
    return customerRepo.findById(customerId)                    // Optional<Customer>
        .flatMap(customer -> accountService.findAccount(customer.getId())) // Optional<Account>
        .filter(account -> account.getBalance().compareTo(amount) >= 0)    // sufficient funds?
        .map(account -> executePayment(account, amount));                   // Optional<PaymentResult>
}

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