4 révélations qui vont transformer votre approche de la null-safety en Java (JSpecify + NullAway)

Publish date: 9 Jan 2026
Tags: java null-safety jspecify nullaway

TL;DR #

Introduction #

La NullPointerException est tellement structurante qu’on en a fait une légende : Tony Hoare a qualifié l’invention de la référence nulle de « billion-dollar mistake1 ».
Et en Java, on a longtemps traité le problème… en surface : une pluie d’annotations, des conventions par framework, et des outils qui ne lisaient pas tous la même sémantique.

Le résultat, vous l’avez vécu :

Ce qui change depuis JSpecify 1.0.0 (stable)2 et l’adoption côté tooling (notamment NullAway, qui supporte JSpecify « out of the box »)3 : on sort de la débrouille. La nullité devient un contrat lisible partout (IDE + build) et vérifiable.

Voici 4 révélations concrètes qui vont changer votre manière d’écrire (et de relire) du Java.


Révélation 1 — L’industrie a enfin trouvé un accord (et ça change tout) #

Avant : des annotations partout, une sémantique nulle part #

Historiquement, “mettre @Nullable” en Java ne voulait pas dire grand-chose sans préciser quel dialecte : JSR-305, JetBrains, Eclipse, Spring, Checker Framework… chacun avec des nuances (défaut nullable vs non-null, portée, compatibilité outils). Spring lui-même raconte avoir construit sa null-safety initiale sur une base JSR-305 “dormante mais répandue”, faute de mieux.1

Maintenant : un standard consensus-driven et tool-independent #

JSpecify 1.0.0 annonce explicitement : les 4 annotations (@Nullable, @NonNull, @NullMarked, @NullUnmarked) sont officielles et ne subiront plus de changements incompatibles.2
Et surtout : la page “About” liste une coalition rare (Google, JetBrains, Oracle, Uber, Broadcom/Spring, Microsoft, Meta, etc.).4

Ce n’est pas “une tentative de plus”. C’est le moment où l’écosystème dit : on arrête de se contredire.

Code : avant / après (import + package) #

// Avant : lequel choisir ?
import javax.annotation.Nullable;
import org.jetbrains.annotations.Nullable;
import org.springframework.lang.Nullable;

// Après : UN standard
import org.jspecify.annotations.Nullable;

Et surtout, vous pouvez poser le contrat au bon endroit : le package.

/**
 * Révélation 1 : JSpecify met fin au "chacun son annotation"
 * <p>
 * Avant JSpecify, chaque framework avait ses propres annotations :
 * javax.annotation.Nullable vs org.jetbrains.annotations.Nullable
 * Spring NonNullApi vs JSR-305 Nonnull
 * <p>
 * Avec JSpecify : un standard unique, des règles claires, des ambiguïtés levées.
 */
@org.jspecify.annotations.NullMarked
package com.headevlabs.tutorials.revelation1;

Exemple complet (projet java-null-safety) : un @NullMarked au niveau package, puis un service dont le retour nullable est obligatoirement traité.

package com.headevlabs.tutorials.revelation1;

import org.jspecify.annotations.Nullable;

/**
 * Exemple de service utilisateur avec JSpecify.
 * Grâce à @NullMarked au niveau du package, tous les types sont non-null par défaut.
 */
public class UserService {

    /**
     * Recherche un utilisateur par son ID.
     * @param userId L'ID de l'utilisateur (non-null par défaut)
     * @return L'utilisateur trouvé, ou null si non trouvé (explicitement @Nullable)
     */
    public @Nullable User findUserById(String userId) {
        if ("unknown".equals(userId)) {
            return null; // OK : le retour est @Nullable
        }
        return new User(userId, "John Doe");
    }

Impact concret : quand votre IDE, votre CI, et vos dépendances “parlent” la même nullité, vous arrêtez de bricoler des conventions locales.


Révélation 2 — La règle d’or est inversée : le non-nul devient la norme #

Le point qui fait basculer Java du “défensif permanent” vers quelque chose de moderne, c’est @NullMarked.

La doc JSpecify résume bien la réalité : dans un scope @NullMarked, les types non annotés sont traités comme @NonNull (donc « non-null par défaut »), ce qui évite de devoir écrire @NonNull partout.5 C’est exactement le rôle de @NullMarked — typiquement posé au niveau package via un package-info.java.6

Avant : annoter 80% du code (bruit) #

public void process(
  @NonNull String a,
  @NonNull String b,
  @NonNull String c
) {}

Après : annoter le paramètre de la méthode (promoCode) #

@NullMarked
public class ShoppingCart {

    // Tous les champs sont non-null par défaut
    private final String cartId;
    private final String userId;
    private final @Nullable String promoCode; // Seul le code promo est optionnel

    /**
     * Constructeur : tous les paramètres sont non-null sauf indication contraire.
     */
    public ShoppingCart(String cartId, String userId, @Nullable String promoCode) {
        this.cartId = cartId;
        this.userId = userId;
        this.promoCode = promoCode;
    }

Exemple concret (projet java-null-safety) : @NullMarked au niveau classe, un champ @Nullable, un paramètre nullable, et un retour nullable.

public void applyDiscount(@Nullable String discountCode) {
        if (discountCode != null) {
            System.out.println("Application du code : " + discountCode);
        } else {
            System.out.println("Aucune réduction appliquée");
        }
    }

    /**
     * Retour nullable : l'annotation est sur le type de retour.
     */
    public @Nullable String getPromoCode() {
        return promoCode;
    }

    /**
     * Démonstration : La sécurité par défaut.
     */
    public static void demonstrateNullMarked() {
        // OK : tous les arguments non-null
        ShoppingCart cart = new ShoppingCart("cart-123", "user-456", null);

        cart.addItem("item-1", "Laptop", "999.99");

        // Le compilateur nous force à gérer le cas null
        @Nullable String promo = cart.getPromoCode();
        if (promo != null) {
            System.out.println("Code promo actif : " + promo);
        }

        // On peut passer null explicitement à applyDiscount
        cart.applyDiscount(null);
    }

Le vrai bénéfice : vous changez ce que “voit” le lecteur #

Soyons directs : si vous adoptez JSpecify mais que vous n’utilisez jamais @NullMarked, vous ratez une grande partie de la valeur (et vous retombez dans le bruit).


Révélation 3 — Vos outils se parlent enfin (vraiment) #

Le problème le plus toxique, ce n’est pas la nullité : c’est l’incohérence.

Le scénario classique #

La bonne nouvelle : JetBrains documente noir sur blanc une coordination entre IntelliJ IDEA et NullAway, notamment pour rendre les suppressions portables.7

IntelliJ reconnaît des suppressions NullAway (ex. NullAway.Init) et NullAway accepte des IDs de suppression “style IntelliJ” pour compatibilité.7

NullAway documente aussi côté compile flags des mécanismes comme SuppressionNameAliases (utile quand votre codebase a déjà des suppressions « non-NullAway », ex. l’inspection IntelliJ DataFlowIssue).8

Démo : le même bug doit apparaître au même endroit #

* Méthode qui peut retourner null.
     * L'IDE et NullAway détectent TOUS LES DEUX l'erreur si on oublie la vérification.
     */
    public @Nullable String processData(String input) {
        if (input.isEmpty()) {
            return null;
        }
        return input.toUpperCase();
    }

    /**
     * Exemple de code dangereux : déréférencement potentiel de null.
     *
     * AVANT : L'IDE avertit, mais le build passe (ou l'inverse).
     * APRÈS : L'IDE ET NullAway détectent l'erreur !
     */
    public void dangerousUsage() {
        @Nullable String result = processData("test");
        List<@Nullable String> results = new ArrayList<>();

        results.add(result);

        // ⚠️ ERREUR détectée par IntelliJ ET NullAway :
        // "dereferenced expression result is Nullable"
        // int length = result.length(); // Décommenter pour voir l'erreur

Même principe (projet java-null-safety) : un @Nullable oublié se voit au même endroit, et une suppression reste portable.

if (result != null) {
            int length = result.length();
            System.out.println("Longueur : " + length);
        } else {
            System.out.println("Résultat est null, pas de traitement." + results);
        }
    }

    /**
     * Suppression d'avertissement : maintenant cohérente !
     *
     * Avant : SuppressWarnings NullAway pour le CI, mais l'IDE ne comprenait pas.

Suppressions : à utiliser comme un scalpel, pas comme un balai #

Cas typique documenté : frameworks à cycle de vie (ex. Spring) où NullAway peut signaler “field not initialized” alors que le conteneur garantit l’initialisation. JetBrains cite explicitement la suppression recommandée NullAway.Init.7

@SuppressWarnings("NullAway.Init")
private Repository repo;

Le point important : si vous devez suppress, faites-le :


Révélation 4 — TYPE_USE : la null-safety devient précise (et donc plus exigeante) #

C’est la partie la plus technique, et c’est là que beaucoup se trompent au début.

TYPE_USE, c’est quoi ? #

Les annotations JSpecify s’appliquent sur l’usage du type (TYPE_USE), pas juste sur “le champ” ou “la méthode”. NullAway explique aussi qu’à partir de certaines versions, il faut placer les annotations au bon endroit (tableaux, types qualifiés), sinon vous aurez des surprises.3

Tableaux : l’erreur la plus fréquente (et l’inversion à connaître) #

Règle mnémotechnique : ce qui est juste après @Nullable peut être null.

// Le tableau PEUT être null, ses éléments (String) sont non-null
String @Nullable [] maybeNullArray;

// Le tableau est non-null, ses éléments PEUVENT être null
@Nullable String[] arrayWithNullableElements;

Cette logique est explicitée dans la doc JSpecify (syntaxe TYPE_USE)9 et reprise par NullAway (placement requis).3

👉 Si vous aviez appris l’inverse, tant mieux : vous venez d’éviter un bug de contrat.

Génériques : puissance maximale, pièges maximaux #

// ==========================================
    // PIÈGE 1 : Position de @Nullable sur les tableaux
    // ==========================================

    /**
     * ❌ ERREUR COURANTE : Confusion sur la position
     *
     * @Nullable String[] array;     // Le TABLEAU peut être null
     * String @Nullable [] array;    // Les ÉLÉMENTS peuvent être null
     */
    public void arrayNullabilityConfusion() {
        // Tableau nullable
        @Nullable String[] maybeArray = null; // OK
        if (maybeArray != null) {
            String first = maybeArray[0]; // OK : éléments non-null
        }

        // Tableau avec éléments nullables
        String @Nullable [] arrayWithNulls = {null, "test"};
        @Nullable String first = arrayWithNulls[0]; // OK : peut être null
    }


    // ==========================================
    // PIÈGE 2 : Génériques imbriqués
    // ==========================================

    /**
     * ❌ ERREUR : Où mettre @Nullable dans List<List<String>> ?
     */
    public void nestedGenericsConfusion() {
        // La liste externe peut être null
        @Nullable List<List<String>> nullableOuter = null;

        // La liste externe est non-null, mais les listes internes peuvent être null
        List<@Nullable List<String>> outerWithNullableInner =
            List.of(List.of("a"), null, List.of("b"));

        // Les éléments String finaux peuvent être null
        List<List<@Nullable String>> innerWithNullableStrings =
            List.of(List.of("a", null));

        // Tout peut être null ! (rare, mais possible)
        @Nullable List<@Nullable List<@Nullable String>> fullyNullable = null;
    }

Extraits (projet java-null-safety) : placement sur tableaux, puis sur génériques imbriqués (et rappel sur l’invariance).

/**
     * ❌ ERREUR : La nullabilité n'est pas covariante
     *
     * List<@Nullable String> n'est PAS un sous-type de List<String>
     */
    public void variancePitfall() {
        List<String> nonNull = List.of("a", "b");

        // ❌ ERREUR de compilation (décommenter pour voir) :
        // List<@Nullable String> nullable = nonNull;

        // ✅ CORRECT : conversion explicite
        List<@Nullable String> nullable = nonNull.stream()
            .map(s -> (@Nullable String) s)
            .collect(Collectors.toList());
    }

Piège à ne pas raconter : List<String> n’est pas un sous-type de List<@Nullable String> en Java. Même si String est “plus strict” que @Nullable String, les génériques sont invariants. Si vous voulez exprimer “liste de strings (potentiellement null)”, vous devez le dire explicitement (List<@Nullable String>) ou passer par des wildcards selon le besoin.

Quand sortir l’artillerie TYPE_USE ? #


Mise en pratique — le défi 1 semaine (sans tout migrer) #

Objectif : preuve par le code, pas un chantier.

1) Ajouter JSpecify (annotations stables) #

JSpecify 1.0.0 est publié sur Maven Central.2

<properties>
        <maven.compiler.source>23</maven.compiler.source>
        <maven.compiler.target>23</maven.compiler.target>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <jspecify.version>1.0.0</jspecify.version>
        <nullaway.version>0.11.3</nullaway.version>
        <errorprone.version>2.33.0</errorprone.version>
    </properties>

    <repositories>
        <repository>
            <id>central</id>
            <name>Maven Central</name>
            <url>https://repo.maven.apache.org/maven2</url>
        </repository>
    </repositories>

    <dependencies>
        <!-- JSpecify: annotations standardisées pour la null-safety -->
        <dependency>
            <groupId>org.jspecify</groupId>
            <artifactId>jspecify</artifactId>
            <version>${jspecify.version}</version>
        </dependency>

Extrait pom.xml (projet java-null-safety) :

2) Null-mark un seul package #

// src/main/java/com/acme/orders/package-info.java
@org.jspecify.annotations.NullMarked
package com.acme.orders;

3) Activer NullAway via Error Prone (Maven) #

NullAway fournit un exemple Maven complet (Error Prone + NullAway sur le processor path + flags).8

<configuration>
                    <source>23</source>
                    <target>23</target>
                    <fork>true</fork>
                    <compilerArgs>
                        <!-- Active Error Prone avec NullAway -->
                        <arg>-XDcompilePolicy=simple</arg>
                        <arg>-Xplugin:ErrorProne -Xep:NullAway:ERROR -XepOpt:NullAway:AnnotatedPackages=com.headevlabs.tutorials</arg>
                        <!-- Permet à Error Prone d'accéder aux classes internes du compilateur -->
                        <arg>-J--add-exports=jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED</arg>
                        <arg>-J--add-exports=jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED</arg>
                        <arg>-J--add-exports=jdk.compiler/com.sun.tools.javac.main=ALL-UNNAMED</arg>
                        <arg>-J--add-exports=jdk.compiler/com.sun.tools.javac.model=ALL-UNNAMED</arg>
                        <arg>-J--add-exports=jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED</arg>
                        <arg>-J--add-exports=jdk.compiler/com.sun.tools.javac.processing=ALL-UNNAMED</arg>
                        <arg>-J--add-exports=jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED</arg>
                        <arg>-J--add-exports=jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED</arg>
                        <arg>-J--add-opens=jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED</arg>
                        <arg>-J--add-opens=jdk.compiler/com.sun.tools.javac.comp=ALL-UNNAMED</arg>
                    </compilerArgs>
                    <annotationProcessorPaths>
                        <path>
                            <groupId>com.google.errorprone</groupId>
                            <artifactId>error_prone_core</artifactId>
                            <version>${errorprone.version}</version>
                        </path>
                        <path>
                            <groupId>com.uber.nullaway</groupId>
                            <artifactId>nullaway</artifactId>
                            <version>${nullaway.version}</version>
                        </path>
                    </annotationProcessorPaths>
                </configuration>
            </plugin>
            <plugin>

Variante (projet java-null-safety) : l’activation se fait via un profil Maven, ce qui permet de l’allumer/éteindre pendant une migration.

Notes “vraies” :

  • adaptez source/target et surtout les versions à votre contexte (JDK, BOM, contraintes d’entreprise).
  • si vous êtes déjà utilisateur NullAway, la doc indique que vous pouvez “swap in” les annotations JSpecify sans générer de nouvelles erreurs en mode standard (hors cas de placement TYPE_USE).3

4) Lancer et observer les premiers vrais bugs #

mvn clean compile

Si vous utilisez un profil (comme dans l’exemple ci-dessus), lancez plutôt mvn clean compile -Pnullaway.

Vous cherchez deux catégories :


Tableau comparatif : Avant / Après #

AspectAvant JSpecifyAprès JSpecify
AnnotationsMultiples, sémantiques divergentesStandard stable (4 annotations)2
Défaut“nullness unspecified” + conventions locales@NullMarked = non-null par défaut5
IDE vs CIDivergences fréquentesAlignement IDE/CI (suppressions)7
Tableaux / génériquessouvent grossierTYPE_USE précis (si maîtrisé)3

Conclusion — plus qu’une rustine : une fondation #

JSpecify ne “supprime” pas null du Java. Il fait mieux : il rend la nullité explicite, standardisée, et vérifiable.5 Et avec NullAway, ce contrat n’est pas un commentaire décoratif : il casse le build quand vous mentez.1

Et si vous travaillez avec Spring, l’écosystème pousse déjà dans cette direction : Spring Framework 7 a basculé sur JSpecify et vise une couverture plus complète (y compris tableaux/génériques), avec enforcement au build.1

Le call-to-action (raisonnable) #

Cette semaine : un package, @NullMarked, NullAway en CI. Vous aurez un retour immédiat, et vous saurez si votre codebase a un problème de contrats… ou juste un problème d’habitudes.


Pièges et limites (à connaître avant de vous emballer) #


Alternatives (quand les choisir) #


  1. Spring Blog — Null Safety in Spring applications with JSpecify and NullAway: https://spring.io/blog/2025/03/10/null-safety-in-spring-apps-with-jspecify-and-null-away    

  2. JSpecify — Release 1.0.0: https://jspecify.dev/blog/release-1.0.0/   

  3. uber/NullAway Wiki — JSpecify Support: https://github.com/uber/NullAway/wiki/JSpecify-Support     

  4. JSpecify — About Us: https://jspecify.dev/about/

  5. JSpecify — Nullness User Guide: https://jspecify.dev/docs/user-guide/  

  6. JSpecify Javadoc — Annotation Interface NullMarked: https://jspecify.dev/docs/api/org/jspecify/annotations/NullMarked.html

  7. JetBrains Blog — One Could Simply Add Nullability Check Support… Without Even Noticing It: https://blog.jetbrains.com/idea/2025/11/one-could-simply-add-nullability-check-support-without-even-noticing-it/    

  8. uber/NullAway Wiki — Configuration: https://github.com/uber/NullAway/wiki/Configuration  

  9. JSpecify — User Guide, “Type-use annotation syntax”: https://jspecify.dev/docs/user-guide/#type-use-annotation-syntax

  10. Stack Overflow — What @Nullable to use in Java (as of 2023/JDK21)?: https://stackoverflow.com/questions/76630457/what-nullable-to-use-in-java-as-of-2023-jdk21

  11. GitHub — jspecify/jspecify release v1.0.0: https://github.com/jspecify/jspecify/releases/tag/v1.0.0