DEV Community

Cover image for Rethinking Optional<?>!
Ivan Juren
Ivan Juren

Posted on

Rethinking Optional<?>!

Compile-Time Safety vs Runtime Safety: JSpecify in Practice

The classic defense mechanism against the billion-dollar mistake in java?
Common wisdom: use Optional, runtime checks, and Objects.requireNonNull.

But none of these eradicated the problem.

Let’s look at a simple case — a UserProfile with an optional email and last login date — and see how the code changes when using JSpecify.


Without JSpecify: Optional All the Way

public final class UserProfile {

    private final String username;
    private final String email;
    private final Instant lastLogin;

    public UserProfile(String username,
                       String email,
                       Instant lastLogin) {
        Objects.requireNonNull(username, "Username must not be null!"); // must check the value to ensure correctness
        //alternatively, check nullness on each usage of username... 
        this.username = username;
        this.email = email;
        this.lastLogin = lastLogin;
    }   // do you test this kind of code? How do you ensure invariants don't break when changes happen?

    public String getUsername() { return username; }

    public Optional<String> getEmail() {    // returns optional so caller is forced to consider Optional.empty()
        return Optional.ofNullable(email);  // exists due to developers experience, but has runtime costs(instantiation, GC) 
    }

    public Optional<Instant> getLastLogin() {
        return Optional.ofNullable(lastLogin);  
    }
    // ... rest of the impl
}
Enter fullscreen mode Exit fullscreen mode

And the consumer:

class MailSenderOptional {

    public void doSomething(UserProfile userProfile) {
        if (someOptional.isPresent() && userProfile.getLastLogin().isPresent()) {
            doSomeLogic(userProfile.getUsername(),
                        userProfile.getEmail().get(),
                        userProfile.getLastLogin().get());
        }
    }

    private void doSomeLogic(String username, String email, Instant lastLogin) {
        Objects.requireNonNull(username);   // given method is private, these checks might be excessive, but had it been 
        Objects.requireNonNull(email);      // public, why/when would caller care about invariants of this class?
        Objects.requireNonNull(lastLogin);  // If this call is deep down, developer will notice only at runtime.
        System.out.println("runtime safe: " + username + " " + email + " " + lastLogin);
    }
}
Enter fullscreen mode Exit fullscreen mode

Here, everything compiles fine, even if you accidentally pass null to email.

The compiler won’t warn you — only a runtime exception can catch that.

Optional usage makes it safer in runtime, but verbose, hard to chain, and doesn’t truly make your API null-safe.

Btw. Have you even run into following scenario:

 public void unlucky(UserProfile userProfile) {
    Optional<String> email = userProfile.getEmail();
    if (email.isPresent()) { // ❌ NullPointerException! variable email is null! 
        // impl...
    }
}
Enter fullscreen mode Exit fullscreen mode

Optionals give you certain safety, but it's not a guarantee. To be honest I've run into this just once in my life so this is more of a theoretical issue.


With JSpecify: Compile-Time Null Awareness

Now, let’s bring in JSpecify and its @NullMarked and @Nullable annotations.

@NullMarked
public final class UserProfile {

    private final String username;
    private final @Nullable String email;
    private final @Nullable Instant lastLogin;

    public UserProfile(String username,
                       @Nullable String email,
                       @Nullable Instant lastLogin) {
        // No need for requireNonNull — compiler checks this.
        this.username = username;
        this.email = email;
        this.lastLogin = lastLogin;
    }

    public String getUsername() { return username; }

    public @Nullable String getEmail() { return email; }    // no need for Optional here, caller is forced to consider nullability

    public @Nullable Instant getLastLogin() { return lastLogin; } // if you're a fan -> these can be generated by lombok 
}
Enter fullscreen mode Exit fullscreen mode

Notice that we no longer have any custom code/checks and that now this class fits into record very nicely so it looks like

@NullMarked
public record UserProfile(String username, @Nullable String email, @Nullable Instant lastLogin) {
}
Enter fullscreen mode Exit fullscreen mode

And the consumer...

@NullMarked
class MailSenderJSpecify {

    public void doSomething(UserProfile userProfile) {
        // ❌ Compile error! email and lastLogin are nullable strings
        // doSomeLogic(userProfile.username(), userProfile.email(), userProfile.lastLogin());  
        // developer has to tackle it right away! 

        if (userProfile.email() != null && userProfile.lastLogin() != null) {
            // ✅ compiler knows they email and lastLogin are non-null within this block.
            doSomeLogic(userProfile.username(),
                        userProfile.email(),
                        userProfile.lastLogin());
        }
    }

    private void doSomeLogic(String username, String email, Instant lastLogin) {
        // no need for runtime checks (or writting tests for it)
        System.out.println("compile safe: " + username + " " + email + " " + lastLogin);
    }
}
Enter fullscreen mode Exit fullscreen mode

...is more concise and safer than ever before. You gotta love modern Java! 🥰


The Differences

Without JSpecify With JSpecify
Compiler is unaware of nullability Compiler knows what can be null
Optional used to signal nullability Plain nullable fields, checked at compile time
Safety at runtime only Safety enforced at compile time
Verbose API (Optional.ofNullable(...)) Natural Java API with null-awareness
Code compiles even if nulls passed incorrectly Code fails to compile if nulls aren’t handled

Conclusion

Null-related bugs are common causes of production incidents and put a lot of strain in the brain-context of the developer building stuff.

JSpecify brings the type system closer to what we intuitively mean in code:

“This might be null” vs “This can never be null.”

And it integrates cleanly — no new types, no wrapper objects, just annotations and compiler support.


TL;DR

  • Before JSpecify: You defend against null at runtime.
  • With JSpecify: You prevent null at compile time.

Compile-time safety beats runtime surprises. 📈


Btw. check my repo where I've created ~20 examples (easy to hard) where you can exercise JSpecify usage and get a hang of it.

Github repo: https://github.com/JurenIvan/jspecify-exercise

Top comments (0)