mattone
dopo mattoneTHE BOOK SERIES
IT/EN
← Java guide

Java 25 · 19/39

19. Optional: When a Search Finds Nothing

Java 25 · Complete guide · Draft under review

This guide retains the book’s draft status. Editorial review and comprehension checks with independent readers remain to be completed.

Search the whole guide →

The Problem Hidden in a Chain of Calls

Imagine looking for a book through its code. The catalog may find the entry, but it may also fail to find it. If the method returns Book and uses null to say “absent”, whoever calls it must remember to check the result. A single forgotten check can lead to a NullPointerException on a line far from the search. In the previous chapter we saw that findFirst() returns a value that may not exist: it is the same problem, expressed through a standard library type.

Some functions cannot produce a result for every input. A search, for example, may find nothing: often null is returned and subsequent calls become filled with if statements to avoid an error. In Java, Optional<T> represents a result containing a non-null value of type T or being empty. The returned type warns whoever reads the signature that absence is an ordinary possibility in the domain. It does not eliminate every check: it places it at an explicit point in the composition, where we can choose whether to transform, supply an alternative, throw an exception or do nothing.

Definition – Absent Value. An empty Optional<Book> is an existing Optional object describing the absence of a book. It is not null, and does not contain a book whose value is null. The variable itself should not be null: returning null from a method declared as Optional<Book> would defeat the contract. The Java 25 documentation identifies Optional mainly as a method return type when “no result” is a normal answer and null would be a source of errors.

The word monad appeared in the previous title. It is a term from the theory of computational structures that may help on an advanced path, but is not needed here to understand the search. We do not attribute to Optional the power to automatically correct a program: an empty result can still be ignored, a side effect can still hide in a function, and a get() on the empty value can still throw an exception. The concrete property that interests us is composing transformations that keep absence visible.

Constructing the Two Cases

The first distinction to master is between Optional.empty(), Optional.of(value) and Optional.ofNullable(value). The first form produces an empty result. The second requires a non-null value and throws NullPointerException if we pass null: it is suitable when the program knows the value must exist and wants to discover a violation of the invariant immediately. The third accepts a reference that may be null: if it is, it returns an empty Optional; otherwise it returns a present Optional.

In the complete program, the method find queries a small catalog Map. Map.get(code) returns the associated book or null if the key does not exist. Right at the boundary with this API, the method converts the possible null into an Optional<Book>:

static Optional<Book> find(String code) {
    return Optional.ofNullable(CATALOG.get(code));
}

When we search for "J-01", the value is present; when we search for "X", the result is empty. find("X").isEmpty() prints true. We can also ask isPresent(), which in the same case would give false. These are questions about the result, rather than methods for directly extracting the book. We can try both, but if the whole path became a succession of if (isPresent()) statements followed by get(), we would return to manually handling every branch at the least expressive point.

Important note – Empty Does Not Mean an Error. If the book code does not appear in the catalog, “no book” can be an expected answer. If instead the catalog is unavailable because of a fault, returning Optional.empty() would make a book that we could not search for appear absent. That fault requires another channel, for example an exception or a result type distinguishing absence and failure. The shape of the type must match the operation's real answers.

Presence, Action and Final Value

When we want to perform an action only if the value exists, ifPresent receives a Consumer<T>. In our case we could write find("J-01").ifPresent(book -> System.out.println(book.title()));. If the search is empty, the lambda is not executed. ifPresentOrElse accepts a second action for the empty case: it is useful when both branches are effects, such as printing a message or updating an interface. It does not return a title; if we need to produce a value for a subsequent expression, a method from the orElse family is more suitable.

orElse("absent") returns the present title or the fallback text. The program's first line goes from Optional<Book> to Optional<String> with map(Book::title) and then to String with orElse("absent"): it prints Java. The call does not construct a fictitious book when the search fails; it only chooses which string to show the reader. If the caller must retain the distinction between a real title and an unfound title, it must not apply orElse too early: it can maintain Optional<String> up to the boundary where a concrete answer is needed.

The method get() extracts the present value but throws NoSuchElementException if the result is empty. An error trace would help us see the failure point, but would not make the call safe. In Java 25, get() is still in the API; the documentation identifies orElseThrow() as the preferred alternative when we want to explicitly express that absence, at that point, is exceptional. The difference is not a trick to avoid an exception: orElseThrow() without arguments also throws NoSuchElementException on empty. The name makes the decision easier to read. If instead we have a valid alternative, we choose it; if we do not need to do anything, we retain the absence.

A Fallback Calculated Immediately or Only When Needed?

The comparison between orElse and orElseGet deserves a complete experiment. Consider Optional.of("Java"): the value exists, so both methods return "Java". Yet argument evaluation is different. In present.orElse(defaultName()), Java calculates defaultName() before entering orElse, because method arguments are evaluated before the call. In present.orElseGet(OptionalDemo::defaultName), we pass a function; Optional invokes it only if empty.

In the program, defaultName() increments a counter to make the call observable. The line defaults calculated: 1 shows that the orElse fallback was calculated even though unused, while the orElseGet fallback was not calculated. If the alternative were a constant such as "unknown", the orElse form would be simple and readable. If it required a query, a file read or an expensive operation, orElseGet would avoid unnecessary work when the value is present. The effect of our counter serves only for the test: in application code, a function called as a fallback should have documented behavior and not depend on hidden call order.

Form Argument passed When the fallback is calculated Result with a present value
orElse(alternative) a value T before calling orElse returns the present value
orElseGet(supplier) a Supplier<T> only if the Optional is empty returns the present value without invoking the supplier

The table does not say that orElseGet is always better. If the alternative value is already available, creating a lambda to return it adds no clarity. On the other hand, a call to a remote service does not become safe merely because we put it in a Supplier: when the Optional is empty, the operation will still happen and can fail. The method's contract must explain what happens in that case.

When Absence Must Become an Exception

A shop's catalog can calmly answer “book not found” to a public search. The same answer may be unacceptable while we prepare a shipment already paid for: at that point the code expects the book to exist. orElseThrow() translates this decision visibly. Without arguments it throws NoSuchElementException; with a Supplier we can construct an exception more relevant to the domain. In the complete example, find("X").orElseThrow(() -> new MissingBookException("X")) produces an error carrying the code searched for. The exception supplier is not invoked if the search succeeds.

Book book = find(code)
        .orElseThrow(() -> new MissingBookException(code));

Here the variable book has type Book, rather than Optional<Book>: after this line the normal flow continues only with a present book. However, if we call orElseThrow immediately after every search, we have lost the reason for choosing Optional: ordinary absence systematically becomes an exception. The method belongs at points where the contract changes, rather than on every line wanting to read a value.

Further reading – Checked Exception. The supplier version is also generic with respect to the exception type. We can supply a checked exception and declare it in the calling method's signature, or use an unchecked one. The choice depends on the operation's contract. A failed search does not in itself force us to choose one of the two families: first we decide whether it really is a failure in that context.

It is easy to confuse orElseThrow with get() because both can extract the value and fail on empty. The practical difference is the readability of the choice; the supplier version also allows us to give the failure a specific meaning. A failure with NoSuchElementException is useful to observe in an experiment: we can run Optional.<Book>empty().get() in an isolated test, observe the exception and then replace it with intentional handling. We do not put that experiment in the program's normal flow, where it would interrupt subsequent examples.

A Second Search with or

Sometimes we want to retain the result as Optional but search elsewhere if the first catalog does not contain the book. or receives a Supplier<Optional<? extends T>>: the supplier must return another Optional, rather than a bare Book. If the first is present, the supplier is not called and the result is the first Optional. If it is empty, the result of the second search is used, and it may still be empty. This is therefore not a promise of success; it is an alternative search plan.

In the program, the search for "R-02" in the first catalog is empty; the supplier consults the backup catalog and finds Networks. The output line is Networks. If the first search already found a book, we would not pay the cost of the second consultation. If both failed, the subsequent map(Book::title).orElse("absent") would make the value to display explicit.

String title = find("R-02")
        .or(() -> findInBackupCatalog("R-02"))
        .map(Book::title)
        .orElse("absent");

The contrast with orElseGet clarifies the types. orElseGet leaves Optional and returns a T; or retains the wrapper and returns an Optional<T>. If the next phase must still distinguish the absent case, or maintains the information. If instead a concrete value is needed at the end of the path, orElseGet is suitable. Code becomes readable when every operation corresponds to a real domain decision, rather than when methods are accumulated merely to avoid an if.

Filtering a Found Result

Suppose the catalog contains the book, but the screen must show only available volumes. filter does not query the catalog: it receives the Optional already produced and checks a Predicate on the present value. If the result was empty, it does not call the predicate and remains empty. If the result was present but the predicate returns false, it becomes empty. If it returns true, the value remains present.

Optional<Book> available = find("J-01")
        .filter(Book::available);

The simplicity of this form has an informational cost: after filter, an empty result can mean “nonexistent code” or “existing but unavailable book”. If the application's reader needs to distinguish the two cases, we must not merge them. We can model the two outcomes with a dedicated type or keep the availability check in the branch where the book is present. This is an important example of the limit of Optional: it carries presence or absence, rather than the reason for every absence.

The predicate must be chosen for its meaning and must be understandable at the point of use. filter(book -> book.price() > 0) makes sense only if we have explained why a positive price is the desired condition. In general, a predicate with side effects makes reasoning about composition less easy; above all, we should not rely on filter to modify the book while pretending to merely check it.

Changing the Contents with map

With map, we transform the present value and leave the empty case unchanged. From Optional<Book> and a function Book -> String, we obtain Optional<String>. In find("J-01").map(Book::title), the function is called on the found book. In find("X").map(Book::title), it is not called, because there is no book to apply it to. We can continue with other transformations without introducing a check at every step:

String screenTitle = find("J-01")
        .map(Book::title)
        .map(String::toUpperCase)
        .orElse("UNAVAILABLE");

The chain is read from left to right. First we search for a book; if we find it, we take its title; if the title exists, we convert it to uppercase; finally we choose what to show when the entire path has not produced a string. The fallback value appears once, at the end, rather than being duplicated in every transformation.

Internally, map applies logic equivalent to ofNullable to the function's result: if the function returns null, the transformation produces Optional.empty(). This is an API property, rather than an invitation to return null without explaining it. In a carefully designed model, a mandatory title should not become null; if it does, correcting the invariant is more useful than hiding the problem behind an empty value. When instead the function really queries optional data, it is best to declare that optionality in its return type and use flatMap.

Reading diagram – map. Optional<Book> followed by Book -> String produces Optional<String>. The container maintains the possibility of absence, while the type of the possibly present value changes. The arrow describes a function: it receives a Book and returns a String; it does not indicate that the book is modified.

Composing Two Searches with flatMap

An author can have an optional name. In our model, author(Book) returns Optional<String>. If we used map(OptionalDemo::author), the transformation would wrap an already optional result and we would obtain Optional<Optional<String>>. To read the name we would have to open two levels: the book may be missing and, even if present, the name may be missing. flatMap applies the function and flattens the result into a single Optional<String>.

Optional<String> authorName = find("J-01")
        .flatMap(OptionalDemo::author);

Let us follow a chain of searches: from the key we may find an object; from the object we may find a datum; only at the end do we decide how to represent empty. If the book search is empty, author is not called. If the book exists but the author field is unavailable, author returns Optional.empty() and the chain's result is empty. If both exist, the name is passed to the next step. In our program the name obtained is Ada.

flatMap requires the function to return a non-null Optional. Writing a function that returns null instead of Optional.empty() violates the contract and may cause a NullPointerException. This is consistent with the method's idea: the result must be able to say explicitly whether the second step found something. map and flatMap are not interchangeable stylistic variants; we choose by looking at the return type of the function we pass.

Further reading – Two Absences, a Single Type. Flattening does not automatically retain the cause. Optional.empty() after flatMap can mean that the book was missing or that its author was missing. When the cause matters, a simple Optional is not enough. We can maintain the first decision in an explicit branch, or define a result type describing the cases. Composition is useful when both empty cases really lead to the same final choice.

From an Optional Result to a Stream

Optional.stream() produces a stream with zero elements when the result is empty and one element when present. This bridge becomes useful when we have many optional results. A list of codes can generate a sequence of Optional<Book> values; flatMap(Optional::stream) eliminates empty results and lets the found books flow through. In the program, the codes "J-01", "X" and "R-02" are searched for first in the main catalog and then, if needed, in the backup catalog. The output is [Java, Networks].

List<String> titles = codes.stream()
        .map(code -> find(code)
                .or(() -> findInBackupCatalog(code)))
        .flatMap(Optional::stream)
        .map(Book::title)
        .toList();

Here the two flatMap methods we have encountered belong to different types. The one on Optional composes a function returning another Optional; the one on Stream takes a stream for each element and concatenates its elements into the resulting flow. Optional::stream is the adapter between the two forms. This detail avoids the impression that this is a formula to memorize: the chain represents three understandable choices, searching, ignoring absences and taking the titles.

A stream pipeline is consumed by the terminal operation toList(). Before that call the transformations are defined but have not yet produced the list. For a single search, constructing a stream would be verbose; for a collection of searches, it allows the rule “retain only successes” to be expressed without sentinels. If the position of absent codes or the reason for absence matters, we do not discard them with flatMap(Optional::stream): we retain a result for every code.

Where Using Optional Is Useful

A search method that may fail to find an object is a good candidate for Optional<T>. A local variable representing an outcome already produced can be one when it makes the flow clearer. The API documentation presents it mainly as a return type, rather than a universal replacement for every reference. A field Optional<Book> in an entity, a parameter Optional<Book> imposed on all callers or a collection full of Optional values can add a wrapper where a more direct contract would suffice. These are choices to evaluate in context, rather than absolute prohibitions.

Optional is a value-based class. We do not compare two instances with == to establish whether they describe the same result, and do not use them as synchronization monitors. To compare contents, equals respects the documented contract. We do not assume that Optional.empty() is always the same object in memory: what matters is that it is empty. The API also offers OptionalInt, OptionalLong and OptionalDouble for optional primitive results, avoiding boxing of the value when that choice is appropriate; their composition does not always coincide with the generic API and must be read in the respective contracts.

Finally, let us try formulating the contract before choosing the method. “The code may not exist” suggests Optional<Book>. “If it is missing in the first catalog, consult the second” suggests or. “If it exists, show the title” suggests map and a final value for the view. “If it is missing during an already confirmed shipment, the operation is inconsistent” suggests orElseThrow at the shipment point. Thus Optional does not become a decorative chain: it makes explicit the decisions that would remain scattered in a sequence of null and if.

A common difficulty emerges when the fallback is itself absent. orElse(null) is permitted by the method, but returns the caller to the need to handle null; we have gained no clarity. orElseGet can in turn receive a supplier returning null, and or can return an empty Optional. Before writing the chain, we ask whether the destination still permits absence. If so, we maintain Optional and leave the decision to the layer owning the context; if not, we choose a meaningful fallback or an appropriate exception. The fallback must not pretend that the search succeeded: a string such as "unknown" can work for a view, but would be a terrible ISBN code to save as real data.

Caller intention Starting method Result type
Know whether the value exists isPresent, isEmpty boolean
Perform an action when it exists ifPresent void
Perform one of two actions ifPresentOrElse void
Transform a present value map Optional<U>
Compose an optional search flatMap Optional<U>
Exclude a value that does not satisfy a rule filter Optional<T>
Search another source or Optional<T>
Obtain a value or a fallback orElse, orElseGet T
Obtain a value or report an error orElseThrow T
Insert the result into a pipeline stream Stream<T>

The table is a guide to reading types, rather than a shortcut for choosing a method by name alone. If we start with Optional<Book> and apply map(Book::title), the letter U in the table is String. If we apply flatMap to a method returning Optional<String>, the result remains a single Optional<String>. This small type exercise allows us to check the chain before even running it and immediately identify an Optional<Optional<...>> that appeared by mistake.

To Verify Understanding. Modify the program by adding a present book without an author and observe the result of flatMap(OptionalDemo::author). Then replace orElseGet with orElse for an empty search and a present one, noting how many times the fallback is calculated. Finally, decide whether a nonexistent code and an unavailable catalog should produce the same answer. Explaining the choice matters more than the syntax used.

A Reasoned Migration from null

Imagine an existing method Book find(String code) returning null when the key is not in the catalog. Before changing the signature, we ask who calls it and what they do with the result. One caller uses if (book != null) to show the entry; another immediately passes the result to a method that does not accept null; a third interprets empty as an error. Changing the signature to Optional<Book> search(String code) makes the possibility of absence explicit, but does not make the three decisions identical. Every caller must choose its own exit point: display a message, request another source or report the inconsistency with an exception.

Migration must happen at the boundary of the API that knows the meaning of null. In our example that boundary is Map.get: Optional.ofNullable(CATALOG.get(code)) translates the map's answer. It is not useful to scatter Optional.ofNullable after every call if the previous method already promised a non-null result; in that case an unexpected null signals a broken contract. Conversely, if an external library uses null as an ordinary outcome, conversion to an Optional in our adapter makes the rest of the code more readable. The choice requires knowing the source's contract, rather than merely the syntax of ofNullable.

A signature change is also visible in tests. Previously a test could assert that the absent search returned null; afterwards it must assert that the Optional is empty. For the present case it can verify that the title is the expected one, without relying on the identity of the Optional object. A third test should ask what happens when the catalog itself fails: returning empty in that case would be a false answer of “code not found”. A good suite measures these three distinct answers, because they are precisely the ones callers must know how to handle.

Further reading – Absence of a Number. If a search optionally calculates an int, Optional<Integer> can describe the outcome, but entails boxing the primitive value. OptionalInt represents presence or absence of an int without using Integer as its contents. It has its own methods, for example empty, of, isPresent, orElse and stream; we do not assume that every generic method of Optional<T> has an identical copy. The choice between the two forms is made for clarity of contract and workload profile, rather than to turn every number in the program into an optional wrapper.

Two Further Cases: Email and Student

The catalog allowed us to follow a single problem throughout the chapter. Two independent examples help us verify that the methods do not depend on the name Book: an optional email and a student's age. In the StudentAndFilters program, a small record Student(String name, Integer age) makes the necessary properties visible without requiring additional libraries. Choosing a record does not change the rules of Optional; it serves only to focus attention on the value that may be missing.

In the first case an Optional<String> contains an email address. filter applies a predicate constructed with a regular expression. With ada@example.org the predicate returns true and the Optional remains present; with ada.example.org it returns false and the result is empty. The program prints true and false. The predicate checks a basic form here, rather than certifying that the mailbox exists or that the address satisfies every possible delivery rule. This clarification matters: filter applies exactly the condition we give it, rather than universal validation hidden inside Optional.

Optional<String> email = Optional.of("ada@example.org");
boolean acceptedForm = email
        .filter(value -> EMAIL_FORM.matcher(value).matches())
        .isPresent();

If the initial value were empty, the predicate would not be executed and isPresent() would still return false. After this pipeline, therefore, false can mean “no address was supplied” or “the supplied address does not respect the chosen form”. If the screen must show two different messages, we need to retain the distinction before filtering. The same limit appeared with book availability: a transformation leading several causes to the same empty value is correct only when those causes really have the same outcome for the caller.

In the second case the field age has type Integer and can be null in the small teaching model. student.map(Student::age) produces Optional<Integer>: when the field is present, the new Optional contains the number; when the field is null, the result is empty. We thus move from Optional<Student> to Optional<Integer> without losing the meaning of absence. The program prints age present: true for the twenty-year-old student. If we change the construction to new Student("Ada", null), the print becomes false. We have not “repaired” a missing mandatory age: we have described a model in which age really may be unknown. If age is required in the domain, the record should prevent its absence rather than entrust the error to a pipeline.

The method optionalName(Student) returns Optional<String>. Starting from Optional<Student>, flatMap(StudentAndFilters::optionalName) avoids the double wrapper and directly produces Optional<String>. The program extracts Ada from it with a declared fallback for the empty case. This way the student example shows, with data different from the catalog, the reason for the types Optional<Integer> and Optional<String> and the difference between map and flatMap. The reader can draw both paths as transformations of types before looking at the output.

Is an Optional Parameter Always a Good Idea?

What changes if the optional value is a parameter of the method filtering students? The documentation presents Optional mainly as a return type; a parameter Optional<Integer> forces every caller to construct a wrapper to say “no filter” or “filter by this age”. In the program we use an Integer age parameter that can be null at the boundary of that method, and translate the choice into an explicit condition: age == null || age.equals(student.age()). This is a local contract example, rather than a recommendation to spread unchecked null throughout the rest of the program.

If the criteria grow, for example name, minimum age, section and availability, many optional parameters become difficult to read. A type SearchCriteria with declared fields and rules can be clearer; a builder can allow the caller to specify only the desired criteria. The decision depends on the API and its callers. The teaching rule to preserve is not “never Optional in parameters” as an absolute prohibition, but “use it when the type really clarifies the contract and does not force an unnecessary wrapper”. Here too the clarity of the call matters as much as the conciseness of the method body.

Reading exercise. Take the list with two students called Ada, one aged twenty and one twenty-one. filterStudents(list, "Ada", 20) returns a single element. With the third argument null, the method returns both. The reader must explain why the condition age == null || age.equals(student.age()) avoids dereferencing the absent parameter and why comparison of the name remains necessary in both cases. Then they can decide whether this contract is clear enough for the imagined application or deserves a dedicated criteria type.

To Verify

Return to the catalog and distinguish three outcomes: code found, code absent and catalog unavailable. Write which type you would return for a successful or absent search and where you would make the source failure visible. Only then choose between map, flatMap, or and orElseThrow to construct a response to the caller: for every step, note the type of the value before and after. One test with a present code and one with an absent code must confirm your predictions; a third test must prevent a fault from being presented as mere absence. The C19 activity allows you to transfer the same distinction to a new problem.

Try the examples

Running the programs requires JDK 25. Download the individual Java files linked in the chapter or the complete example project, which includes instructions and a launcher script. The explanations also compare expected output: predict it before running the program.

Massimiliano Tarquini · CC BY-NC 4.0

Back to the top ↑