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
}
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);
}
}
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...
}
}
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
}
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) {
}
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);
}
}
...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)