- How many times have you encountered a NullPointerException from who-knows-where?
- How many times have you thought, āIf only that developer had gracefully handled null values,ā while debugging?
- How many times have you been lost while reading verbose null checks like (x == null && y != null && z == nullā¦) cluttering the code?
What if I told you thereās a better way? Javaās Optional class not only cleans up code but enforces better practices, making value presence (or absence) explicit. This reduces runtime surprises, creates clearer APIs, and leads to more maintainable applications.
But why should you care? If youāre reading this, I believe youāre always looking to improve your style and write cleaner, more professional code.
However, spotting where Optional can improve your code isnāt always easy, especially for beginners. Thatās why Iām sharing real-world examples where refactoring with Optional not only prevents null pointer errors but also improves readability, safety, and performance.
As always, youāll find all the sample code in my GitHub repo. Thereās a separate class for Exercises where you can practice refactoring using these concepts. If you get stuck, answers are on a separate branch.
š¢ Null Checks In Conditional Statements
public int getDiscount(Customer customer) {
if (customer != null && customer.discount() != null) {
return customer.discount();
}
return 0;
}
Here, our goal is to return customerās discount, but we first check that both
customerandcustomer.discount()are notnull. If either check fails, we return a default value 0.
public int getDiscountAfterRefactor(Customer customer) {
return Optional.ofNullable(customer)
.map(Customer::discount)
.orElse(0);
}
In this refactored version, using
Optional.ofNullable()safely handles potentialnullvalues forcustomerorcustomer.discount(), whileOptional.orElse(0)provides a default value if the discount is missing. This makes the code more concise and readable.
š¢ Returning Null From Methods
public Address getAddress(User user) {
if (user != null && user.address() != null) {
return user.address();
}
return null;
}
Like the previous example, we validate the object before returning
user.address(). But returning null makes the client code responsible for handlingnullchecks, increasing the risk ofNullPointerException.
public Optional<Address> getAddress(User user) {
return Optional.ofNullable(user)
.map(User::address);
}
In this improved version,
Optional<Address>as a return type of the method clearly signals that the value may be absent. This approach requires the caller to handle missing values explicitly, improving safety and clarity.
š¢ Chaining Method Calls
public String getCountryName(User user) {
if (user != null && user.address() != null && user.address().country() != null) {
return user.address().country().name();
}
return "Unknown";
}
Here, we rely on nested
nullchecks just to accesscountry.name(), making the code verbose and harder to follow. With deeply nested objects containing multiple nullable layers, this approach quickly becomes unmanageable.
public String getCountryName(User user) {
return Optional.ofNullable(user)
.map(User::address)
.map(Address::country)
.map(Country::name)
.orElse("Unknown");
}
With
Optional, we achieve clean and safe method chaining without the need for nestedifstatements. This makes the code more compact and readable.
š¢ Throwing Exceptions With Optional
public User findUserById(Long id) {
User user = userRepository.findById(id);
if (user == null) {
throw new RuntimeException("User not found for id: " + id);
}
return user;
}
An
ifstatement checks for a missinguserand throws an exception when none is found.
public User findUserById(Long id) {
return Optional.ofNullable(userRepository.findById(id))
.orElseThrow(() -> new RuntimeException("User not found for id: " + id));
}
The refactored code with
Optional.orElseThrow()simplifies this logic and clearly signals that an exception will be thrown if theuseris missing, improving readability and conciseness.
š¢ Using Empty Optional For Clear Intent
public Optional<Product> findProductByName(String name) {
Product product = productRepository.findByName(name);
if (product == null) {
return null;
}
return Optional.of(product);
}
When a method returns
Optional<T>, the caller expects anOptional, notnull. Ifnullis returned, calling anyOptionalmethod leads to aNullPointerException, defeating the very reasonOptionalexists.
public Optional<Product> findProductByName(String name) {
return Optional.ofNullable(productRepository.findByName(name))
.or(Optional::empty);
}
Explicitly returning
Optional.empty()when no product is found ensures the method never returnsnull, making the code more robust.
š¢ Conditional Logic With Optional.filter()
public Optional<User> getActiveUser(User user) {
if (user != null && user.isActive()) {
return Optional.of(user);
}
return Optional.empty();
}
In this example, we are using
nullchecks combined with a boolean condition.
public Optional<User> getActiveUser(User user) {
return Optional.ofNullable(user)
.filter(User::isActive);
}
Refactoring with
Optional.filter()allows us to clearly handle the case of an active user, making the intent explicit and reducing unnecessary conditional logic, resulting in cleaner, more functional code.
š¢ Performing Actions Conditionally
public void notifyUser(User user) {
if (user != null) {
sendNotification(user);
}
}
In this example, we are checking the
userto be present before proceeding with any action.
public void notifyUser(User user) {
Optional.ofNullable(user)
.ifPresent(this::sendNotification);
}
After refactoring, using
Optional.ifPresent()clarifies that the action occurs only if theuseris notnull, resulting in simplified and expressive code.
public void processLastOrder(Long orderId) {
Optional<Order> orderOpt = orderRepository.findLatestByCustomerId(orderId);
if (orderOpt.isPresent()) {
handleOrder(orderOpt.get());
} else {
handleMissingOrder();
}
}
To bring the above example even further, here we are checking for the presence of
order, performing different actions based on the result.
public void processOrder(Long orderId) {
orderRepository.findLatestByCustomerId(orderId)
.ifPresentOrElse(this::handleOrder, this::handleMissingOrder);
}
Using
ifPresentOrElse()clearly separates actions for when theOptionalis present and when it isnāt, simplifying the logic and removing manual checks.
š„ Want to hear some of the best practices for using Optional?
Optional is many things, but there are even more things that it is not. Make sure to check out these practices not to misuse or overuse this helpful tool in your code.
š” Avoid using Optional in fields
Using Optional for fields complicates serialization and deserialization. For example, if a field is declared as Optional<String>, it may not be immediately obvious for someone reading the code that they need to handle both the Optional wrapper and the actual value inside it.
public class User {
private Optional<String> email;
public User(Optional<String> email) {
this.email = email;
}
public Optional<String> getEmail() {
return email;
}
}
Optional is meant to be used as a return type for methods to show that a value might be absent, not for fields in a class.
Keep the class design simple.
public class User {
private String email;
public User(String email) {
this.email = email;
}
public String getEmail() {
return email;
}
}
š” Avoid using Optional in collections
Using Optional in collections, like List<Optional<User>>, adds unnecessary complexity. Optional is designed to represent a single value that may or may not be present, not to hold multiple values. Additionally, each Optional you create in a collection introduces a performance cost, which can add up in large lists.
List<Optional<User>> users = ...;
List<User> validUsers = users.stream()
.filter(Optional::isPresent)
.map(Optional::get)
.collect(Collectors.toList());
Instead of using Optional for individual elements in a collection, itās better to work directly with the collection of actual objects and filter out any null or absent ones during data collection.
Keep the logic and the code simple and clean.
List<User> validUsers = userIds.stream()
.map(userService::findById)
.filter(Objects::nonNull)
.collect(Collectors.toList());
š” Use orElse() and orElseGet() wisely
orElse(T other) method evaluates the provided fallback value regardless of whether the Optional contains a value. This can lead to unnecessary computations if the fallback value is expensive to create, especially if the Optional is often present.
public class UserService {
private String generateDefaultUsername() {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
return "GuestUser";
}
}
orElseGet(Supplier<? extends T> other) only runs the Supplier (which creates the fallback value) when the Optional is empty. This lazy evaluation improves performance, especially if generating the fallback value is resource-intensive.
Use
orElseGet()instead oforElse()when creating a default value is expensive.
public String getUsername(String userId) {
Optional<String> username = findUsername(userId);
String defaultUsername = username.orElseGet(this::generateDefaultUsername);
return defaultUsername;
}
š” Donāt overuse Optional
Optional is a great tool for signaling the potential absence of a value, but overusing it can lead to less readable code.
public Optional<Integer> getProductCount() {
return Optional.of(100);
}
If a method is expected to return a primitive or a straightforward value, using Optional may complicate things unnecessarily.
Donāt clutter your code.
public int getDefaultDiscount() {
return 10;
}
Hopefully, you now see how using Optional in your daily workflows can help you focus on writing cleaner, functional code.
Due to the limited format, we have not discussed all the helpful tools in Optional class. Read about the rest of the methods in the docs, like Optional.or() and Optional.orElseGet() and check out the exercises in my repo to give them a try.