6.4 Optional and Null Safety
Optional<T> is a container that may or may not hold a non-null value. It encourages explicit handling of absent values, reducing NullPointerException risks and making intent clear in APIs.
Creating Optionals
// Empty optional
Optional<String> empty = Optional.empty();
// Optional with value
Optional<String> present = Optional.of("Hello");
// Optional that may be null
String nullableValue = getValue(); // may return null
Optional<String> maybe = Optional.ofNullable(nullableValue);
// Optional.of throws NPE if value is null
Optional<String> willThrow = Optional.of(null); // NullPointerException!
Checking for Values
Optional<String> opt = Optional.of("value");
// Check if present
if (opt.isPresent()) {
String value = opt.get();
}
// Java 11+: Check if empty
if (opt.isEmpty()) {
// handle absence
}
Consuming Values
ifPresent: Execute action if value exists
Optional<String> opt = findUserName();
// Old way
if (opt.isPresent()) {
System.out.println(opt.get());
}
// Better way
opt.ifPresent(System.out::println);
// Real-world example
Optional<User> user = userRepository.findById(userId);
user.ifPresent(u -> auditLog.recordAccess(u.id()));
ifPresentOrElse: Handle both cases (Java 9+)
opt.ifPresentOrElse(
value -> System.out.println("Found: " + value),
() -> System.out.println("Not found")
);
// Example: update or create
userRepository.findByEmail(email).ifPresentOrElse(
user -> user.updateLastLogin(),
() -> userRepository.create(new User(email))
);
Providing Default Values
orElse: Return default if empty
String name = findUserName().orElse("Guest");
// With objects
User user = findUser(id).orElse(User.DEFAULT);
orElseGet: Compute default lazily
// orElse always evaluates, even if value present
String name = opt.orElse(generateDefaultName()); // always called
// orElseGet only evaluates if needed
String name = opt.orElseGet(() -> generateDefaultName()); // lazy
// Real-world: expensive default
User user = cache.get(id).orElseGet(() -> database.findUser(id));
orElseThrow: Throw exception if empty
// Default exception
String value = opt.orElseThrow(); // NoSuchElementException
// Custom exception
User user = findUser(id)
.orElseThrow(() -> new UserNotFoundException(id));
// Common pattern in services
public User getUserById(String id) {
return userRepository.findById(id)
.orElseThrow(() -> new EntityNotFoundException("User", id));
}
Transforming Optionals
map: Transform the value if present
Optional<String> name = Optional.of("alice");
Optional<String> upper = name.map(String::toUpperCase); // Optional["ALICE"]
Optional<String> empty = Optional.empty();
Optional<String> stillEmpty = empty.map(String::toUpperCase); // Optional.empty
// Chain transformations
record User(String name, Address address) {}
record Address(String city) {}
Optional<User> user = findUser();
Optional<String> city = user
.map(User::address)
.map(Address::city);
flatMap: Transform to another Optional
// When transformation returns Optional
Optional<User> user = findUser(id);
Optional<Address> address = user.flatMap(User::getAddress);
// Without flatMap, you'd get Optional<Optional<Address>>
Optional<Optional<Address>> nested = user.map(User::getAddress);
// Real-world: chained lookups
record Order(String id, String userId) {}
Optional<String> customerEmail = findOrder(orderId)
.flatMap(order -> findUser(order.userId()))
.flatMap(user -> user.getEmail());
filter: Keep value only if predicate matches
Optional<String> name = Optional.of("Alice");
Optional<String> longName = name.filter(n -> n.length() > 5);
// Optional.empty (Alice has 5 chars)
Optional<String> shortName = name.filter(n -> n.length() <= 5);
// Optional[Alice]
// Real-world: validation
Optional<User> activeUser = findUser(id)
.filter(User::isActive)
.filter(user -> !user.isBanned());
// Permission check
Optional<Document> accessible = findDocument(docId)
.filter(doc -> doc.isPublic() || doc.ownerId().equals(currentUserId));
Optional Streams (Java 9+)
stream: Convert Optional to Stream
Optional<String> opt = Optional.of("value");
Stream<String> stream = opt.stream(); // Stream with 1 element
Optional<String> empty = Optional.empty();
Stream<String> emptyStream = empty.stream(); // Empty stream
// Practical: flatten optional results
List<String> usernames = userIds.stream()
.map(id -> findUser(id)) // Stream<Optional<User>>
.flatMap(Optional::stream) // Stream<User>
.map(User::name)
.toList();
// Before Java 9, you'd need filter + map
List<String> usernames = userIds.stream()
.map(id -> findUser(id))
.filter(Optional::isPresent)
.map(Optional::get)
.map(User::name)
.toList();
or: Provide alternative Optional (Java 9+)
Optional<String> primary = findInCache(key);
Optional<String> fallback = primary.or(() -> findInDatabase(key));
// Chain multiple sources
Optional<Config> config = loadFromFile()
.or(() -> loadFromEnv())
.or(() -> loadDefaults());
Anti-Patterns to Avoid
DON'T use Optional as a field
// Bad
class User {
private Optional<String> middleName;
}
// Good
class User {
private String middleName; // can be null
public Optional<String> getMiddleName() {
return Optional.ofNullable(middleName);
}
}
DON'T use get() without checking
// Bad - can throw NoSuchElementException
String name = optional.get();
// Good
String name = optional.orElse("default");
// or
String name = optional.orElseThrow(() -> new IllegalStateException());
DON'T use isPresent() + get()
// Bad - defeats the purpose
if (optional.isPresent()) {
String value = optional.get();
System.out.println(value);
}
// Good
optional.ifPresent(System.out::println);
DON'T pass Optional as method parameter
// Bad
public void processUser(Optional<User> user) { }
// Good - use overloading or null
public void processUser(User user) { }
public void processUserIfPresent(User user) { }
DON'T create Optional just to avoid null
// Bad - unnecessary wrapping
public Optional<String> getName() {
String name = computeName();
return Optional.ofNullable(name);
}
// Good - return null if that's the contract
public String getName() {
return computeName(); // may return null
}
// Optional is for when absence is meaningful
public Optional<User> findUser(String id) {
// Signals "user might not exist"
}
Real-World Examples
Example 1: Configuration with fallbacks
import java.util.*;
class ConfigManager {
private final Map<String, String> overrides = new HashMap<>();
private final Map<String, String> defaults = Map.of(
"timeout", "30",
"retries", "3",
"host", "localhost"
);
public void setOverride(String key, String value) {
overrides.put(key, value);
}
public String getConfig(String key) {
return Optional.ofNullable(overrides.get(key))
.or(() -> Optional.ofNullable(System.getenv(key)))
.or(() -> Optional.ofNullable(defaults.get(key)))
.orElseThrow(() -> new ConfigException("Missing config: " + key));
}
public int getInt(String key) {
return Optional.ofNullable(overrides.get(key))
.or(() -> Optional.ofNullable(System.getenv(key)))
.or(() -> Optional.ofNullable(defaults.get(key)))
.map(Integer::parseInt)
.orElse(0);
}
}
Example 2: User profile with optional fields
record UserProfile(
String userId,
String email,
Optional<String> phoneNumber,
Optional<String> website,
Optional<String> bio
) {
// Factory method with nulls
public static UserProfile create(
String userId,
String email,
String phoneNumber,
String website,
String bio
) {
return new UserProfile(
userId,
email,
Optional.ofNullable(phoneNumber),
Optional.ofNullable(website),
Optional.ofNullable(bio)
);
}
// Get contact info, preferring phone
public Optional<String> getPreferredContact() {
return phoneNumber.or(() -> website);
}
// Check if profile is complete
public boolean isComplete() {
return phoneNumber.isPresent()
&& website.isPresent()
&& bio.isPresent();
}
}
// Usage
void demonstrateUserProfile() {
UserProfile profile = UserProfile.create(
"user123",
"user@example.com",
"+1234567890",
null,
"Software developer"
);
// Safe access to optional fields
profile.phoneNumber().ifPresent(phone ->
System.out.println("Phone: " + phone)
);
// Provide defaults
String website = profile.website().orElse("No website");
// Transform optional values
Optional<Integer> bioLength = profile.bio().map(String::length);
}
Example 3: Repository pattern with Optional
interface Repository<T, ID> {
Optional<T> findById(ID id);
List<T> findAll();
T save(T entity);
void deleteById(ID id);
}
class UserRepository implements Repository<User, String> {
private final Map<String, User> storage = new HashMap<>();
@Override
public Optional<User> findById(String id) {
return Optional.ofNullable(storage.get(id));
}
public Optional<User> findByEmail(String email) {
return storage.values().stream()
.filter(user -> user.email().equals(email))
.findFirst();
}
@Override
public User save(User user) {
storage.put(user.id(), user);
return user;
}
@Override
public void deleteById(String id) {
storage.remove(id);
}
@Override
public List<User> findAll() {
return new ArrayList<>(storage.values());
}
}
// Service layer using repository
class UserService {
private final UserRepository repository;
public UserService(UserRepository repository) {
this.repository = repository;
}
public User getOrCreateUser(String email) {
return repository.findByEmail(email)
.orElseGet(() -> {
User newUser = new User(
UUID.randomUUID().toString(),
email,
LocalDateTime.now()
);
return repository.save(newUser);
});
}
public void updateUserEmail(String userId, String newEmail) {
repository.findById(userId)
.map(user -> user.withEmail(newEmail))
.map(repository::save)
.orElseThrow(() -> new UserNotFoundException(userId));
}
public Optional<String> getUserEmail(String userId) {
return repository.findById(userId)
.map(User::email);
}
}
Example 4: Validation with Optional
class Validator {
public static Optional<String> validateEmail(String email) {
if (email == null || email.isBlank()) {
return Optional.empty();
}
if (!email.contains("@")) {
return Optional.empty();
}
return Optional.of(email.toLowerCase());
}
public static Optional<Integer> validateAge(String ageStr) {
try {
int age = Integer.parseInt(ageStr);
if (age < 0 || age > 150) {
return Optional.empty();
}
return Optional.of(age);
} catch (NumberFormatException e) {
return Optional.empty();
}
}
}
record RegistrationRequest(String email, String age) {}
class RegistrationService {
public Optional<User> register(RegistrationRequest request) {
return Validator.validateEmail(request.email())
.flatMap(email ->
Validator.validateAge(request.age())
.map(age -> new User(
UUID.randomUUID().toString(),
email,
age
))
);
}
}
// Usage
void demonstrateRegistration() {
var service = new RegistrationService();
var request = new RegistrationRequest("user@example.com", "25");
service.register(request)
.ifPresentOrElse(
user -> System.out.println("Registered: " + user.email()),
() -> System.out.println("Invalid registration data")
);
}
Optional Best Practices
Use Optional for return types when absence is expected
public Optional<User> findUser(String id) { }Don't use Optional for collections—return empty collection
// Bad public Optional<List<User>> getUsers() { } // Good public List<User> getUsers() { return List.of(); // or Collections.emptyList() }Use method chaining to avoid nested ifs
// Bad Optional<User> optUser = findUser(id); if (optUser.isPresent()) { User user = optUser.get(); Optional<Address> optAddress = user.getAddress(); if (optAddress.isPresent()) { Address address = optAddress.get(); // use address } } // Good findUser(id) .flatMap(User::getAddress) .ifPresent(address -> { // use address });Prefer orElseGet for expensive defaults
// Bad - always computes default String value = opt.orElse(expensiveComputation()); // Good - computes only if needed String value = opt.orElseGet(() -> expensiveComputation());Use Optional.stream() for collections of Optionals
List<String> emails = users.stream() .map(User::getEmail) // Stream<Optional<String>> .flatMap(Optional::stream) .toList();
Performance Considerations
- Optional adds minor overhead (object allocation)
- Avoid in performance-critical loops
- Use primitive optionals for primitives:
OptionalInt,OptionalLong,OptionalDouble
// Boxing overhead
Optional<Integer> boxed = Optional.of(42);
// No boxing
OptionalInt primitive = OptionalInt.of(42);
For hot paths, consider direct null checks:
// High-performance code
public String getName(String id) {
String name = cache.get(id);
return name != null ? name : "default";
}
// API boundary
public Optional<String> findName(String id) {
return Optional.ofNullable(cache.get(id));
}