Null-Safe Programming with Optional

java java21 scala kotlin optional null-safety interview-preparation

This is Part 4 of our Java 21 Interview Preparation series. We’ll explore the Optional API and null-safe programming patterns, comparing Java 21’s approach with Scala 3’s Option and Kotlin’s built-in null-safety.

The Problem: Handling Missing Values

One of the most common sources of bugs in Java applications is the dreaded NullPointerException. Traditional null checking leads to verbose, error-prone code with nested conditionals.

Problem Statement: Implement a service that fetches user preferences with fallback defaults, avoiding null checks.

Traditional Null Checking (Don’t Do This!)

// Nested null checks - verbose and error-prone
String getTheme(String userId) {
    User user = database.get(userId);
    if (user != null) {
        UserPreference pref = user.getPreference();
        if (pref != null) {
            String theme = pref.getTheme();
            if (theme != null) {
                return theme;
            }
        }
    }
    return "light"; // default
}

Optional API Basics

Java 8 introduced Optional<T> to represent values that may or may not be present. Modern Java (9+) has enhanced this API significantly.

Creating Optionals

// From nullable value
Optional<String> opt1 = Optional.ofNullable(maybeNull);

// From non-null value (throws if null)
Optional<String> opt2 = Optional.of("value");

// Empty optional
Optional<String> opt3 = Optional.empty();
// From nullable value (handles null from Java interop)
val opt1: Option[String] = Option(maybeNull)

// Explicit Some/None
val opt2: Option[String] = Some("value")
val opt3: Option[String] = None
// Kotlin uses nullable types instead of Optional
val opt1: String? = maybeNull

// Non-null value
val opt2: String = "value"

// Null value
val opt3: String? = null

Extracting Values: orElse, orElseGet, orElseThrow

orElse() - Provide Default Value

Use when the default is already computed or cheap to create.

// Returns theme or "light" if empty
String theme = findUserPreference(userId)
    .map(UserPreference::theme)
    .orElse("light");
// getOrElse is equivalent to orElse
val theme = findUserPreference(userId)
  .flatMap(_.theme)
  .getOrElse("light")
// Elvis operator (?:) is equivalent to orElse
val theme = findUserPreference(userId)?.theme ?: "light"

orElseGet() - Lazy Default Computation

Use when the default is expensive to compute.

// Supplier is only called if Optional is empty
String theme = findUserPreference(userId)
    .map(UserPreference::theme)
    .orElseGet(() -> computeExpensiveDefault());

// orElse: default is ALWAYS evaluated
opt.orElse(expensiveOperation()); // expensiveOperation() called even if opt has value!

// orElseGet: default is only evaluated if needed
opt.orElseGet(() -> expensiveOperation()); // expensiveOperation() called only if opt is empty
// getOrElse is already lazy in Scala (by-name parameter)
val theme = opt.getOrElse(computeExpensiveDefault())
// Elvis operator is already lazy
val theme = opt ?: computeExpensiveDefault()

orElseThrow() - Throw on Absence

Use when absence is exceptional and should be an error.

UserPreference pref = findUserPreference(userId)
    .orElseThrow(() -> 
        new NoSuchElementException("User not found: " + userId));
val pref = findUserPreference(userId)
  .getOrElse(throw new NoSuchElementException(s"User not found: $userId"))
val pref = findUserPreference(userId)
    ?: throw NoSuchElementException("User not found: $userId")

Transformation: map(), flatMap(), filter()

map() - Transform Value

Use when transformation returns a non-Optional value.

// Transform theme to uppercase if present
Optional<String> uppercase = findUserPreference(userId)
    .map(UserPreference::theme)
    .map(String::toUpperCase);
val uppercase = findUserPreference(userId)
  .flatMap(_.theme)
  .map(_.toUpperCase)
// Safe call operator (?.) is equivalent to map
val uppercase = findUserPreference(userId)?.theme?.uppercase()

flatMap() - Avoid Nested Optionals

Use when transformation returns an Optional.

// validateTheme returns Optional<String>
Optional<String> validTheme = findUserPreference(userId)
    .map(UserPreference::theme)
    .flatMap(this::validateTheme);

// Without flatMap, you'd get Optional<Optional<String>>!
// flatMap prevents Option[Option[T]]
val validTheme = findUserPreference(userId)
  .flatMap(_.theme)
  .flatMap(validateTheme)
// Use let for flatMap-like behavior
val validTheme = findUserPreference(userId)
    ?.theme
    ?.let { validateTheme(it) }

filter() - Conditional Processing

Keep value only if predicate matches.

// Only keep font sizes >= 14
Optional<Integer> largeFontSize = findUserPreference(userId)
    .map(UserPreference::fontSize)
    .filter(size -> size >= 14);
val largeFontSize = findUserPreference(userId)
  .flatMap(_.fontSize)
  .filter(_ >= 14)
// takeIf is equivalent to filter
val largeFontSize = findUserPreference(userId)
    ?.fontSize
    ?.takeIf { it >= 14 }

Java 9+ Enhancements

ifPresentOrElse() - Handle Both Cases

findUserPreference(userId)
    .map(UserPreference::theme)
    .ifPresentOrElse(
        theme -> System.out.println("User theme: " + theme),
        () -> System.out.println("Using default theme")
    );
// Pattern matching handles both cases elegantly
findUserPreference(userId).flatMap(_.theme) match
  case Some(theme) => println(s"User theme: $theme")
  case None        => println("Using default theme")

// Or using fold
findUserPreference(userId)
  .flatMap(_.theme)
  .fold(println("Using default theme"))(t => println(s"User theme: $t"))
// When expression with nullable
when (val theme = findUserPreference(userId)?.theme) {
    null -> println("Using default theme")
    else -> println("User theme: $theme")
}

or() - Alternative Optional Source

Provide a fallback Optional when the first is empty.

String theme = findUserPreference(userId)
    .map(UserPreference::theme)
    .or(() -> getFallbackTheme())    // Returns Optional<String>
    .orElse("light");
val theme = findUserPreference(userId)
  .flatMap(_.theme)
  .orElse(getFallbackTheme)
  .getOrElse("light")
val theme = findUserPreference(userId)?.theme
    ?: getFallbackTheme()
    ?: "light"

Refactoring Nested Null Checks

Before: Nested Null Checks

// Verbose and error-prone
String getProcessedTheme(String userId) {
    UserPreference user = database.get(userId);
    if (user != null) {
        String theme = user.theme();
        if (theme != null) {
            if (isValidTheme(theme)) {
                return theme.toUpperCase();
            }
        }
    }
    return "LIGHT";
}

After: Fluent Optional Chain

String getProcessedTheme(String userId) {
    return findUserPreference(userId)
        .map(UserPreference::theme)
        .filter(this::isValidTheme)
        .map(String::toUpperCase)
        .orElse("LIGHT");
}
def getDisplaySettings(userId: String): Option[String] =
  for
    pref <- findUserPreference(userId)
    theme <- pref.theme
    fontSize <- pref.fontSize
  yield s"$theme theme, ${fontSize}px font"
fun getDisplaySettings(userId: String): String? =
    findUserPreference(userId)?.run {
        theme?.let { t ->
            fontSize?.let { s ->
                "$t theme, ${s}px font"
            }
        }
    }

Complete Example: Preference Resolution

Here’s a complete example showing preference resolution with fallbacks:

public ResolvedPreferences resolvePreferences(String userId) {
    Optional<UserPreference> userPref = findUserPreference(userId);
    
    return new ResolvedPreferences(
        userId,
        userPref.map(UserPreference::theme).orElse(DEFAULT_THEME),
        userPref.map(UserPreference::language).orElse(DEFAULT_LANGUAGE),
        userPref.map(UserPreference::fontSize).orElse(DEFAULT_FONT_SIZE),
        userPref.map(UserPreference::notificationsEnabled)
            .orElse(DEFAULT_NOTIFICATIONS)
    );
}
def resolvePreferences(userId: String): ResolvedPreferences =
  val userPref = findUserPreference(userId)
  
  ResolvedPreferences(
    userId = userId,
    theme = userPref.flatMap(_.theme).getOrElse(DefaultTheme),
    language = userPref.flatMap(_.language).getOrElse(DefaultLanguage),
    fontSize = userPref.flatMap(_.fontSize).getOrElse(DefaultFontSize),
    notificationsEnabled = 
      userPref.flatMap(_.notificationsEnabled).getOrElse(DefaultNotifications)
  )
fun resolvePreferences(userId: String): ResolvedPreferences {
    val userPref = findUserPreference(userId)
    
    return ResolvedPreferences(
        userId = userId,
        theme = userPref?.theme ?: DEFAULT_THEME,
        language = userPref?.language ?: DEFAULT_LANGUAGE,
        fontSize = userPref?.fontSize ?: DEFAULT_FONT_SIZE,
        notificationsEnabled = userPref?.notificationsEnabled ?: DEFAULT_NOTIFICATIONS
    )
}

Anti-patterns to Avoid

1. Using isPresent() with get()

// DON'T DO THIS - defeats the purpose of Optional
Optional<String> opt = getTheme();
if (opt.isPresent()) {
    return opt.get();
}
return "default";

// DO THIS INSTEAD
return getTheme().orElse("default");

2. Optional as Method Parameter

// DON'T DO THIS - forces callers to create Optional
void setTheme(Optional<String> theme)

// DO THIS INSTEAD - use @Nullable annotation
void setTheme(@Nullable String theme)

3. Returning null from Optional-returning Method

// DON'T DO THIS
Optional<String> getTheme() {
    if (condition) return null;  // BAD!
    return Optional.of("dark");
}

// DO THIS INSTEAD
Optional<String> getTheme() {
    if (condition) return Optional.empty();
    return Optional.of("dark");
}

4. Optional for Collection Fields

// DON'T DO THIS
Optional<List<String>> getItems()

// DO THIS INSTEAD - return empty collection
List<String> getItems()

5. Kotlin: Excessive !! (Not-Null Assertion)

// DON'T DO THIS - throws NPE if null
val theme = preference?.theme!!

// DO THIS INSTEAD
val theme = preference?.theme ?: "default"

Language Comparison

Operation Java Optional Scala Option Kotlin
Wrap nullable Optional.ofNullable(x) Option(x) x (nullable type)
Create present Optional.of(x) Some(x) x (non-null)
Create empty Optional.empty() None null
Default value orElse(default) getOrElse(default) ?: default
Lazy default orElseGet(() -> ...) getOrElse(...) (lazy) ?: ... (lazy)
Throw if empty orElseThrow(...) getOrElse(throw ...) ?: throw ...
Transform map(f) map(f) ?.let { f(it) }
Flatten flatMap(f) flatMap(f) ?.let { f(it) }
Filter filter(p) filter(p) ?.takeIf { p(it) }
Handle both ifPresentOrElse(f, g) match/fold when expression
Fallback source or(() -> ...) orElse(...) ?: ... ?: ...

Best Practices Summary

  1. Prefer Optional for return types, not for fields or parameters
  2. Use orElseGet() over orElse() for expensive default computations
  3. Chain operations instead of nesting null checks
  4. Never return null from Optional-returning methods
  5. Consider flatMap() when your transformation returns Optional
  6. Use filter() for conditional processing
  7. Leverage Java 9+ features like ifPresentOrElse() and or()

Code Samples

See the complete implementations in our repository:

Conclusion

Modern Java’s Optional API provides a robust way to handle nullable values, though it’s more verbose than Scala’s Option or Kotlin’s built-in null-safety. Key takeaways:

  • Java 21: Use Optional with fluent API chains; leverage Java 9+ additions like ifPresentOrElse() and or()
  • Scala 3: Option is deeply integrated with for-comprehensions and pattern matching
  • Kotlin: Built-in null-safety with ?., ?:, and scope functions eliminates the need for a wrapper type

For Scala developers, Java’s Optional will feel familiar but more verbose. The good news is that modern Java (9+) has significantly improved the Optional API, making null-safe code more idiomatic.


This is Part 4 of our Java 21 Interview Preparation series. Check out Part 1: Immutable Data with Java Records, Part 2: Sealed Classes and Exhaustive Pattern Matching, Part 3: Collection Factory Methods and Stream Basics, and the full preparation plan.