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

  1. Use Optional for return types when absence is expected

    public Optional<User> findUser(String id) { }
    
  2. 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()
    }
    
  3. 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
        });
    
  4. Prefer orElseGet for expensive defaults

    // Bad - always computes default
    String value = opt.orElse(expensiveComputation());
    
    // Good - computes only if needed
    String value = opt.orElseGet(() -> expensiveComputation());
    
  5. 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));
}