If you’ve written Java for more than a week, you already know NullPointerException. The annoying part isn’t the exception itself — it’s that nothing in the method signature warned you it could happen. JSpecify fixes that by giving Java a standard way to say „this can be null“ or „this can never be null,“ so tools can catch the mistake beforehand.
The problem JSpecify solves
Java has had nullability annotations for over a decade — @Nullable from JetBrains, from the Checker Framework, from Android, from Eclipse, from FindBugs. They all do roughly the same thing but aren’t compatible with each other, so a library using one flavor can’t be understood by a tool expecting another.
JSpecify (released 1.0 in 2024, backed by Google, JetBrains, VMware, Oracle-adjacent contributors, and the Checker Framework team) is the industry’s attempt at one standard set of annotations that every static analysis tool and IDE can agree on.
Important: JSpecify does nothing at runtime. There’s no automatic exception thrown. It’s purely metadata that static analysis tools (like NullAway, Error Prone, or IntelliJ’s built-in inspector) read to warn you at compile/review time.
The core annotations
| Annotation | Meaning |
|---|---|
@Nullable | This type may be null. |
@NonNull | This type may never be null (rarely written explicitly — it’s the default). |
@NullMarked | Applied to a package, class, method, constructor, or module.“ |
@NullUnmarked | Opt out of @NullMarked for a specific class/method (useful for legacy code). |
In practice, you almost always use @NullMarked at the package level (via package-info.java) and then sprinkle @Nullable only where null is genuinely allowed. That inverts Java’s historical default — „assume nullable, hope for the best“ — into „assume non-null, opt in to nullable.“
Setup
Add the dependency (Maven):
xml
<dependency> <groupId>org.jspecify</groupId> <artifactId>jspecify</artifactId> <version>1.0.1</version></dependency>
To actually get warnings, you need a checker. The most common pairing is NullAway (an Error Prone plugin), added as an annotation processor. IntelliJ IDEA also understands JSpecify out of the box, so even without a build-time checker you’ll see underlines in the editor.
Example 1: A plain @NullMarked class
java
package com.example.user;import org.jspecify.annotations.NullMarked;import org.jspecify.annotations.Nullable;@NullMarkedpublic class User { private final String username; // never null private final @Nullable String nickname; // may be null public User(String username, @Nullable String nickname) { this.username = username; this.nickname = nickname; } public String displayName() { // Without the null check below, NullAway/IntelliJ flags this line: // "nickname might be null" return nickname != null ? nickname : username; }}
Because the class is @NullMarked, username is non-null by default — nobody needs to annotate it. nickname is explicitly opted into nullability. If you tried to write return nickname.toUpperCase(); without the null check, a JSpecify-aware checker would flag it immediately, at build time, instead of you finding out in production.
Example 2: Reading a config file safely
File I/O is a great showcase for JSpecify because so many JDK methods (like Properties.getProperty(String) or Map.get(Object)) return @Nullable by nature — the key might not exist.
java
package com.example.config;import org.jspecify.annotations.NullMarked;import org.jspecify.annotations.Nullable;import java.io.IOException;import java.nio.file.Files;import java.nio.file.Path;import java.util.Properties;@NullMarkedpublic class ConfigLoader { private final Properties props = new Properties(); public ConfigLoader(Path configFile) throws IOException { try (var in = Files.newInputStream(configFile)) { props.load(in); } } /** Returns the value, or null if the key isn't present. */ public @Nullable String get(String key) { return props.getProperty(key); // Properties.getProperty is @Nullable upstream } /** Returns the value, or throws with a clear message if missing. */ public String require(String key) { String value = get(key); if (value == null) { throw new IllegalStateException("Missing required config key: " + key); } return value; // checker knows value is non-null here — "smart cast" }}
Two things worth noticing:
- The signature documents intent. Anyone reading
get(String)immediately knows they must null-check the result — no need to read the implementation or the Javadoc. - Flow-sensitive narrowing. After the
if (value == null) { throw ... }block, the checker knowsvalueis non-null for the rest of the method — you don’t need a cast or a suppressed warning.
Usage:
java
ConfigLoader config = new ConfigLoader(Path.of("app.properties"));String timeout = config.get("timeout.ms");if (timeout != null) { System.out.println("Timeout is " + timeout);}String dbUrl = config.require("db.url"); // guaranteed non-null, no check needed
Example 3: Kafka Streams — where null is meaningful
In Kafka, a record with a null value is a Delete Signal— a signal to delete a key in a compacted topic. That means null isn’t a bug in Kafka Streams code, it’s a legitimate business signal, which makes explicit nullability annotations especially valuable here: you want the compiler to make sure you handle the Delete Signal case, not accidentally NPE on it.
java
package com.example.streams;import org.apache.kafka.streams.KafkaStreams;import org.apache.kafka.streams.StreamsBuilder;import org.apache.kafka.streams.kstream.KStream;import org.jspecify.annotations.NullMarked;import org.jspecify.annotations.Nullable;@NullMarkedpublic class OrderStatusTopology { public KafkaStreams build(StreamsBuilder builder) { KStream<String, @Nullable String> orderStatuses = builder.stream("order-status-updates"); KStream<String, String> normalized = orderStatuses // mapValues receives a @Nullable value — the signature forces us // to think about the Delete Signal case instead of forgetting it. .mapValues(OrderStatusTopology::normalizeStatus) .filter((key, value) -> value != null); // drop Delete Signal downstream normalized.to("order-status-normalized"); return new KafkaStreams(builder.build(), streamsConfig()); } /** * Normalizes a status string, or returns null to propagate * a delete signal (record deletion) downstream. */ static @Nullable String normalizeStatus(@Nullable String rawStatus) { if (rawStatus == null) { return null; // delete signal in, delete signal out } return rawStatus.strip().toUpperCase(); } private static java.util.Properties streamsConfig() { var props = new java.util.Properties(); props.put("application.id", "order-status-app"); props.put("bootstrap.servers", "localhost:9092"); return props; }}
Why this matters in practice: without an explicit @Nullable on normalizeStatus’s parameter and return type, it’s easy to forget the delete signal case entirely and write rawStatus.strip() directly — which works fine in testing (where you rarely produce a delete signal) and then throws an NPE in production the first time a real deletion flows through the topic. The annotation turns an implicit, easy-to-miss contract („Kafka values can be null“) into something the compiler actively checks for you.
- It’s opt-in. Nothing forces a library author to annotate their code, so you’ll often mix
@NullMarkedcode with unannotated legacy code — that’s what@NullUnmarkedand generics-level nullability (List<@Nullable String>) are for. - No runtime enforcement. JSpecify won’t throw for you; it only helps tools warn you earlier. You still need
Objects.requireNonNull(...)at the boundaries where you want a real runtime guard (e.g., public API entry points). - Generics get verbose.
Map<String, @Nullable String>is correct but easy to forget;Map<@Nullable String, String>is a different (and unusual) thing entirely — annotation placement matters. - JSpecify doesn’t add any new runtime behavior to Java — its value is entirely in making an implicit, tribal-knowledge contract („can this be null?“) into something explicit that both humans and tools can check. It’s especially valuable at the seams of a system — file/config parsing, external APIs, and stream processing systems like Kafka Streams — where „missing value“ is a first-class business concept rather than a bug.

Hinterlasse einen Kommentar