Skip to content
SparkTime, home
Java From Zero to Job-Ready
Lessons
1 · Java Foundations
2 · Object-Oriented Java
3 · Core Java
4 · Collections and Generics
5 · Functional Java
6 · The JVM and Concurrency
7 · Tools and Craft
8 · Databases and JDBC
9 · Web and Spring
10 · Persistence with JPA
11 · Secure, Tested, Production-Ready
12 · Interviews and Readiness
Lesson 17 of 41

Lesson 17 · Functional Java

Null and Optional

Why null causes so many bugs, what Optional is actually for, how to use map, flatMap, orElse, orElseGet and orElseThrow, and the common misuses (fields, parameters, get without a check). Optional is a tool for return values, not a universal replacement for null.

On this page

Key takeaways

  • null means different things (absent, unknown, not yet set, error), and the type system does not tell you where it may appear. That is why NullPointerException is so common.
  • Optional is a container that makes the possible absence of a return value visible in the type. Its Javadoc describes it as intended primarily for method return types where there is a clear need to represent no result.
  • Prefer map, flatMap, filter, orElse, orElseGet, orElseThrow and ifPresent over isPresent followed by get. Use orElseGet when the fallback is expensive or has side effects, because orElse always evaluates its argument.
  • Do not use Optional for fields, method parameters, collection elements or as a wrapper for everything. Return an empty collection instead of null or Optional for collections.
  • Validate required arguments early with Objects.requireNonNull, and model absence deliberately instead of passing null around.

You call user.getAddress().getCity().toUpperCase() and the program crashes with a NullPointerException. Which of the three calls returned null? The message may tell you, and the code does not. Nothing in the type of getAddress() warned that the result might be missing.

This is the most common runtime error in Java, and it comes from a design choice: any reference variable can be null, and the compiler does not track where. Tony Hoare, who introduced null references into a language in 1965, later called it his "billion-dollar mistake". This article explains why null is a problem, what to do about it in general, and how Optional helps in one specific, useful role.

What you will learn

#
  • Why null is ambiguous and hard to reason about.
  • General habits that reduce null problems with or without Optional.
  • What Optional is, and what it is designed for.
  • The operations that make Optional useful: map, flatMap, filter and the or... family.
  • The common misuses, and when not to use it.

The problem with null

#

null is a reference that points to nothing. Calling a method on it throws NullPointerException (NPE). The deeper problem is meaning. When a method returns null, does that mean "not found", "not applicable", "an error occurred", "not yet loaded", or "the programmer forgot to set it"? Different callers read it differently, so every caller must remember to check, and one missing check is a crash.

Java
// File: Main.javapublic class Main {    static class Address {        private final String city;        Address(String city) {            this.city = city;        }        String city() {            return city;        }    }    static class User {        private final String name;        private final Address address;      // may be null        User(String name, Address address) {            this.name = name;            this.address = address;        }        Address address() {            return address;        }    }    static String cityOf(User user) {        return user.address().city().toUpperCase();    }    static String cityOfSafely(User user) {          // the defensive version: a pyramid of checks        if (user != null) {            Address address = user.address();            if (address != null) {                String city = address.city();                if (city != null) {                    return city.toUpperCase();                }            }        }        return "UNKNOWN";    }    public static void main(String[] args) {        User asha = new User("Asha", new Address("Pune"));        User ravi = new User("Ravi", null);        System.out.println(cityOf(asha));        System.out.println(cityOfSafely(ravi));        System.out.println(cityOf(ravi));    }}
Code
PUNEUNKNOWNException in thread "main" java.lang.NullPointerException: Cannot invoke "Main$Address.city()" because the return value of "Main$User.address()" is null

The first call works. The defensive version works but is a pyramid of checks that obscures the actual logic. The last call crashes. Since Java 14, the NPE message says exactly what was null (here, the result of the address() call). Helpful, but it still happens at runtime, in production, after the fact.

Habits that help, with or without Optional

#

Before reaching for a new class, apply the basics:

  1. Do not return null for collections. Return an empty list, set or map. Callers can then loop without a check.
  2. Fail fast on required arguments. Objects.requireNonNull(value, "value must not be null") at the top of a method or constructor turns a distant crash into an immediate, explained one.
  3. Initialise fields. A field that is never null (an empty list, an empty string, a default) removes a whole class of checks.
  4. Make "absence" a deliberate part of the design. Decide for each value whether it can legitimately be missing, and document and model that.
  5. Prefer values that cannot be null (records with validated constructors, enums, sealed types).
  6. Do not use null to signal errors. Throw an exception, or return a result type (article 10).

A third-party approach is to annotate parameters and returns with @Nullable or @NonNull and let tools check them. Those annotations are not part of the standard library, and they are used by many frameworks, including Spring, but they are conventions and not a language feature.

Optional: making absence visible

#

Optional<T> (Java 8) is a container that either holds one non-null value or is empty. The Javadoc describes it as a container object which may or may not contain a non-null value, and gives its purpose precisely: it is primarily intended for use as a method return type where there is a clear need to represent "no result", and where using null is likely to cause errors. A variable of type Optional should never itself be null.

Return type Optional<User> tells every caller, in the signature, "this may find nothing". The compiler cannot force you to handle the empty case, but the API makes it hard to forget.

Creating an Optional

#

Factory

Use

Optional.of(value)

The value is definitely non-null (throws NullPointerException if it is null)

Optional.ofNullable(value)

The value may be null: gives empty if it is

Optional.empty()

No value

Using an Optional: the operations

#

The aim is to avoid unwrapping manually. These methods let you describe what to do with the value, whether or not it is there.

Java
// File: Main.javaimport java.util.List;import java.util.NoSuchElementException;import java.util.Optional;public class Main {    record Address(String city) {}    record User(String name, Address address, String email) {        Optional<Address> findAddress() {            return Optional.ofNullable(address);        }        Optional<String> findEmail() {            return Optional.ofNullable(email);        }    }    static Optional<User> findUser(List<User> users, String name) {        return users.stream().filter(u -> u.name().equals(name)).findFirst();    }    public static void main(String[] args) {        List<User> users = List.of(                new User("Asha", new Address("Pune"), "asha@example.com"),                new User("Ravi", null, null));        // map: transform the value if present; flatMap: when the function itself returns an Optional        String city = findUser(users, "Asha")                .flatMap(User::findAddress)                .map(Address::city)                .map(String::toUpperCase)                .orElse("UNKNOWN");        String missing = findUser(users, "Ravi")                .flatMap(User::findAddress)                .map(Address::city)                .orElse("UNKNOWN");        String nobody = findUser(users, "Zed")                .flatMap(User::findAddress)                .map(Address::city)                .orElse("UNKNOWN");        System.out.println(city + " " + missing + " " + nobody);        // filter: keep the value only if it satisfies a condition        System.out.println(findUser(users, "Asha").flatMap(User::findEmail).filter(e -> e.endsWith("@example.com")).isPresent());        // ifPresent / ifPresentOrElse: perform an action        findUser(users, "Ravi").flatMap(User::findEmail).ifPresentOrElse(                e -> System.out.println("email: " + e),                () -> System.out.println("no email on file"));        // orElseThrow: absence is an error here        try {            findUser(users, "Zed").orElseThrow(() -> new IllegalArgumentException("no such user: Zed"));        } catch (IllegalArgumentException e) {            System.out.println(e.getMessage());        }        try {            findUser(users, "Zed").orElseThrow();            // no-argument form: NoSuchElementException        } catch (NoSuchElementException e) {            System.out.println("NoSuchElementException: " + e.getMessage());        }        // or: an alternative Optional; stream: zero or one element in a stream        System.out.println(findUser(users, "Zed").or(() -> findUser(users, "Ravi")).map(User::name).orElse("?"));        System.out.println(users.stream().map(User::findEmail).flatMap(Optional::stream).toList());    }}
Code
PUNE UNKNOWN UNKNOWNtrueno email on fileno such user: ZedNoSuchElementException: No value presentRavi[asha@example.com]

Compare this with the pyramid of null checks. The chain flatMap(...).map(...).map(...).orElse("UNKNOWN") describes the happy path once, and the empty case flows through to the end without any if. The important distinction between the two transforming methods:

  • map(f) applies f to the value and wraps the result. If f returns null, you get an empty Optional.
  • flatMap(f) is for functions that already return an Optional. It avoids ending up with an Optional<Optional<T>>.

The terminal operations, from safest to least safe:

Method

Behaviour

orElse(fallback)

The value, or the fallback

orElseGet(supplier)

The value, or the supplier's result (computed only if needed)

orElseThrow(supplier) / orElseThrow()

The value, or throws. The no-argument form (Java 10) throws NoSuchElementException and is the documented preferred alternative to get()

ifPresent(consumer), ifPresentOrElse(a, b)

Perform an action

isPresent(), isEmpty()

Test (use sparingly)

get()

The value, or throws if empty. Avoid, because it is a hidden check that people forget

orElse versus orElseGet

#

This difference is a classic interview question and a real source of bugs. orElse(x) takes a value, so the expression for x is always evaluated, even when the Optional has a value. orElseGet(supplier) takes a function and only calls it when needed.

Java
// File: Main.javaimport java.util.Optional;public class Main {    static int expensiveCalls = 0;    static String loadDefault() {        expensiveCalls++;        return "default";    }    public static void main(String[] args) {        Optional<String> present = Optional.of("value");        String a = present.orElse(loadDefault());              // loadDefault() runs even though not needed        System.out.println(a + " calls=" + expensiveCalls);        String b = present.orElseGet(() -> loadDefault());     // not called: the value is present        System.out.println(b + " calls=" + expensiveCalls);        String c = Optional.<String>empty().orElseGet(() -> loadDefault());        System.out.println(c + " calls=" + expensiveCalls);    }}
Code
value calls=1value calls=1default calls=2

Use orElse for a cheap constant (orElse("UNKNOWN")) and orElseGet whenever computing the fallback costs something or has a side effect (a database call, a log entry, creating an object).

Optional in streams

#

You have already seen Optional from stream terminal operations: findFirst, findAny, min and max return one. Optional::stream (Java 9) turns an Optional into a stream of zero or one elements, which lets flatMap(Optional::stream) skip the empty ones, as in the last line of the example. Numeric streams have their own OptionalInt, OptionalLong and OptionalDouble, such as the result of average().

Common misuses

#

Optional is often overused, which creates noise without benefit. Avoid these:

Misuse

Why it is a problem

Instead

A field of type Optional<T>

Optional is not Serializable, adds an object per field, and JPA and many frameworks do not expect it

A nullable field, with an accessor that returns Optional<T>

A method parameter of type Optional<T>

Callers must wrap values, and the parameter itself could still be null

Overloads, or a plain parameter that may be null, documented

opt.isPresent() followed by opt.get()

That is a null check in disguise, and hides nothing

ifPresent, map, orElse and friends

Calling get() without checking

Throws NoSuchElementException: the same crash, with a different name

orElseThrow() with a message

Optional.of(x) when x may be null

Throws an NPE immediately

Optional.ofNullable(x)

A method returning Optional that sometimes returns null

Defeats the whole point

Return Optional.empty()

Optional<List<T>> or Optional<Collection>

An empty collection already means "nothing"

Return an empty collection

Wrapping every return value

Noise when absence is not possible

Use Optional only where "no result" is a real outcome

Comparing with == against Optional.empty()

The documentation says there is no guarantee that it is a singleton

isEmpty() or isPresent()

Using it for synchronisation or identity

It is a value-based class, and the documentation warns against it

Do not

Chaining dozens of operations

Unreadable

Extract methods

Here is a short before and after:

Java
// File: Main.javaimport java.util.List;import java.util.Optional;public class Main {    record Product(String name, Double discount) {}    // Misuse: isPresent + get, and the fallback is computed eagerly    static double finalPriceBad(Product p, double price) {        Optional<Double> discount = Optional.ofNullable(p.discount());        if (discount.isPresent()) {            return price - discount.get();        }        return price;    }    // Better: describe the transformation    static double finalPrice(Product p, double price) {        return Optional.ofNullable(p.discount()).map(d -> price - d).orElse(price);    }    // Collections: never null, never Optional    static List<String> tagsFor(String productName) {        return productName.equals("pen") ? List.of("stationery", "office") : List.of();    }    public static void main(String[] args) {        Product pen = new Product("pen", 2.0);        Product book = new Product("book", null);        System.out.println(finalPriceBad(pen, 10) + " " + finalPriceBad(book, 10));        System.out.println(finalPrice(pen, 10) + " " + finalPrice(book, 10));        for (String tag : tagsFor("unknown product")) {      // safe: an empty list simply loops zero times            System.out.println(tag);        }        System.out.println(tagsFor("pen"));    }}
Code
8.0 10.08.0 10.0[stationery, office]

Optional is not a universal replacement for null

#

It is tempting to conclude that null is banned and Optional is the fix for everything. The Javadoc says otherwise: it is for return values with a clear need to represent "no result". A balanced approach:

  • Return values that may be absent: Optional<T>. This is the main use (a lookup by id, a search, a first match).
  • Fields and parameters: allow null internally where you must, validate with Objects.requireNonNull where you must not, and keep the nullability out of your public API where you can.
  • Collections and arrays: empty, never null.
  • Alternatives that fit better: a default value, an exception for "must exist", a sealed result type (a record Found and a record NotFound, as in article 9) when the reason for absence matters.
  • Boundaries: values arriving from a database, JSON or a library can be null. Convert them at the boundary (Optional.ofNullable, or a default) so that the interior of your code deals with clean values.

In the Job Application Tracker project, repository methods such as findById return Optional<JobApplication>, and the service turns an empty result into a NotFound exception in one place (article 33).

Common mistakes

#
  • Using Optional for fields or parameters.
  • Calling get() without a check.
  • Writing isPresent() then get() where map or ifPresent is clearer.
  • Using orElse with an expensive or side-effecting call.
  • Returning null from a method whose type is Optional.
  • Using Optional.of with a value that can be null.
  • Wrapping collections in Optional.
  • Treating Optional as a substitute for validation.

Exercises

#

Exercise 1

Fix the chain

#

Rewrite String zip = user.getAddress().getZipCode().trim(); using Optional, assuming getAddress() and getZipCode() may return null, producing "none" when anything is missing.

Exercise 2: orElse or orElseGet?

#

Which would you use, and why: (a) name.orElse("anonymous"), (b) fallback is repository.loadDefaultFromDatabase(), (c) fallback is new ArrayList<>()?

Exercise 3: Find the misuse

#

Identify the problems in this signature: public void register(Optional<String> email, Optional<List<String>> tags), and propose a better design.

Exercise 4: map or flatMap?

#

Optional<User> user and Optional<Address> findAddress(User u). Write the expression that gets the user's address, and explain why map would give the wrong type.

Exercise 5: Design a return type

#

You write findByEmail(String email) on a repository. Choose a return type among User, User or null, Optional<User> and throwing an exception, and justify. Then say what changes for getById(long id) on an entity that must exist.

Show solutionHide solution
Java
String zip = Optional.ofNullable(user)        .map(User::getAddress)        .map(Address::getZipCode)        .map(String::trim)        .orElse("none");

Each map returns an empty Optional if the function returns null, so a missing address or zip code short-circuits through the rest of the chain to "none". If getAddress() itself returned an Optional<Address>, you would use flatMap for that step.

Exercise 2

Show solutionHide solution

(a) orElse: a constant is cheap and has no side effect. (b) orElseGet(repository::loadDefaultFromDatabase): it avoids a database call when the value is present, since orElse would run it every time. (c) orElseGet(ArrayList::new), because creating an object per call is a small cost that you should only pay when needed (and it avoids sharing one mutable instance).

Exercise 3

Show solutionHide solution

Both parameters are Optional, which should not be used for parameters: it forces callers to wrap values, the parameter itself could still be null, and Optional<List<String>> is doubly redundant because an empty list already means "no tags". A better design is register(String email, List<String> tags) with Objects.requireNonNull(tags) (callers pass an empty list when there are none), and an overload or documented null for an optional email: register(String email) and register(String email, List<String> tags). If a value is required, validate it instead of making it optional.

Exercise 4

Show solutionHide solution

Optional<Address> address = user.flatMap(this::findAddress); The map method would apply the function and wrap its result, so user.map(this::findAddress) has type Optional<Optional<Address>>, an Optional nested inside an Optional. flatMap expects a function that already returns an Optional, and flattens the result into a single Optional<Address>.

Exercise 5

Show solutionHide solution

For findByEmail, Optional<User> is the best fit: "no user with that email" is a normal outcome that callers must decide how to handle, and the signature says so. Returning null hides the possibility, and throwing an exception makes a normal outcome exceptional. For getById(long id) where the entity must exist (for example when following a foreign key that the database guarantees), returning the User and throwing a not-found exception when it is absent can be right, because an absent value is a bug or an inconsistent state. The decision is about whether absence is a normal result or an error.

Where to go next

#

Sources & further reading

  1. docs.oracle.com
  • Guide · Technology

    Java Essentials for DSA

  • Guide · Technology

    Heaps and Priority Queues

  • Course · Technology

    DSA From Zero to Job-Ready: A Practical Guide with Java