Les 29 et 30 avril derniers, j’ai eu la chance de me rendre à Lyon pour participer à la conférence MIXIT.
Cette conférence mélange savamment des sujets divers traitant aussi bien de technique, de travail en équipe que d’éthique. Elle se distingue également par ses sujets qui sortent des sentiers battus, des présentations dites “aliens”.
C’est dans le cadre de cet évènement que j’ai assisté à une présentation technique donnée par Sébastien Deleuze intitulée “Null Safety en Java avec JSpecify et NullAway”.
JSpecify est une librairie proposant des annotations d’expression de la nullabilité et NullAway un plugin d’analyse statique de code.
Deux raisons m’ont poussé à suivre cette présentation :
- D’une part, elle traite d’une problématique que déjà bien des librairies ont tenté d’adresser. S’il faut vous en convaincre, je vous invite à consulter cette question sur StackOverflow qui dépeint la jungle autour des annotations d’expression de la nullabilité.
- D’autre part, la mise en place de JSpecify est un des principaux chantiers sur lesquels travaillent les équipes de Spring Framework et Spring Boot pour la prochaine version majeure. Chez ekino, nous utilisons ces deux frameworks sur de nombreux projets.
Quel est le problème ?
Sébastien part de l’extrait de code Java ci-dessous pour illustrer le problème :
interface TokenExtractor {
/**
* Extract a token from a {@link String}.
* @param input the input to process
* @return the extracted token
*/
String extractToken(String input);
}
Si l’implémentation de la méthode extractToken retourne une String null, alors une NullPointerException sera levée par le code suivant :
String token = extractor.extractToken("…");
System.out.println("The token has a length of " + token.length());
| Note : le code est disponible sur le GitHub de Sebastien Deleuze
Pour peu que ce retour null n’apparaisse que dans certains cas spécifiques, non identifiés dans votre chaîne de test, il se peut que l’erreur ne se produise qu’en production. On peut alors aisément imaginer les conséquences pour l’utilisateur final, ainsi que l’impact sur l’image de marque.
En Java, un type non primitif peut, soit être null, soit être une référence sur un objet. Si nous reprenons notre exemple ci-dessus, String token = extractor.extractToken("…") signifie que token est soit null, soit une référence sur un objet de type String.
Il est donc nécessaire, lorsque l’on récupère une valeur de retour, ou encore lorsque l’on passe un paramètre à une méthode, de nous assurer de si ce dernier peut être null ou non.
Pour cela, nous pouvons évidemment nous référer à la JavaDoc, encore faut-il qu’elle existe et qu’elle le précise. Si ce n’est pas le cas, il nous faut alors directement étudier le code pour nous en assurer.
Le problème de fond n’est pas l’existence des références null dans le langage mais simplement que cette nullabilité des types non primitifs est implicite en Java.
JSpecify, rendre cette nullabilité explicite
Vous l’aurez compris, l’objectif de JSpecify est donc de rendre explicite le fait qu’une valeur de retour, qu’un paramètre, qu’un champ puisse être null ou non.
JSpecify est en quelque sorte un successeur de la JSR 305, il s’agit d’un ensemble d’annotations et de spécifications qui vont permettre d’exprimer la nullabilité explicitement dans notre code Java.
La promesse étant d’empêcher, ou tout du moins de limiter, le nombre de NullPointerException pouvant être levées lors de l’exécution de notre application.
JSpecify est le fruit d’un travail associant plusieurs organisations parmi lesquelles nous retrouvons, entre autres, Google, JetBrains, Microsoft, Oracle, Sonar, Broadcom.
La spécification introduit trois états :
- Unspecified : nous ne savons pas si ça peut être null ou non
- Nullable : peut être null
- Non-null : n’est pas null
Pourquoi introduire trois états là où un langage comme Kotlin n’en a que deux (Nullable, NonNull) ?
| Note : nous verrons un peu plus loin que cette affirmation n’est pas tout à fait exacte
Tout simplement pour des raisons de rétrocompatibilité et de support de l’existant, l’état Unspecified correspond en réalité au mode de fonctionnement par défaut de Java.
Pourquoi ne pas utiliser des Optional<T> ?
En attendant le projet Valhalla et les value types, les Optional sont une enveloppe vers un potentiel objet. Leur utilisation représente donc un coût en termes de consommation mémoire et de CPU.
Utiliser les Optional pour exprimer la nullabilité impliquerait également de changer toutes les signatures des méthodes existantes de votre code et donc, dans le cas de librairies, de casser la signature des APIs. L’impact sur les utilisateurs de vos APIs serait donc important.
De plus, Optional, comme le spécifie sa JavaDoc, est destiné à être utilisé pour des valeurs de retour, pas pour des paramètres de méthode, ni pour des champs.
Enfin, son utilisation augmente la complexité des signatures de méthodes et donc la lisibilité du code.
Pourquoi pas l’une des nombreuses librairies existantes ?
Il existe déjà de nombreuses librairies d’annotations permettant d’exprimer la nullabilité en Java (Jetbrains, Jakarta, JSR 305…).
La force de JSpecify ne vient pas des annotations en elles-mêmes mais du travail de spécification et de documentation qu’il y a eu autour.
C’est cette collaboration des différents acteurs de l’écosystème sur près de 5 ans qui la distingue des autres implémentations.
Ces spécifications et cette documentation ont certes un intérêt pour les développeurs mettant en place JSpecify dans leur projet, mais aussi et surtout pour les éditeurs d’outils de validation tels que NullAway, IDEA IntelliJ, Eclipse…
Les spécifications laissent peu de place à l’interprétation. Nous pouvons ainsi espérer retrouver, à l’usage des différents outils de validation, les mêmes comportements, le même fonctionnement…
Concrètement
JSpecify introduit à ce jour quatre annotations qui permettent d’adresser les différents cas auxquels nous pouvons être confrontés.
@Nullable
Quand un type est annoté avec @Nullable, la valeur du type peut être null.
@Nullable String extractToken(@Nullable String input);
Ici, nous pouvons passer en paramètre de la méthode extractToken un paramètre input null.
De même le retour de la méthode peut être null.
@NonNull
Quand un type est annoté avec @NonNull, la valeur du type n’est pas null.
@Nullable String extractToken(@NonNull String input);
Ici, nous ne pouvons pas passer null en paramètre de la méthode extractToken mais celle-ci peut nous renvoyer null.
@NullMarked
Cette annotation permet de définir un périmètre comme non null.
Elle a été introduite de manière à éviter d’avoir à systématiquement annoter tous les types avec @NonNull. Elle peut être placée sur un module, un package, une classe ou encore une méthode. Lorsqu’elle est positionnée, tout type non annoté dans le périmètre est considéré comme s’il était annoté avec @NonNull.
@NullMarked
interface TokenExtractor {
@Nullable String extractToken(String input);
}
Ici, l’interface TokenExtractor étant annotée avec @NullMarked, tous les types sont par défaut NonNull, c’est le cas par exemple du paramètre de méthode input.
Il reste tout à fait possible de spécifier des exceptions, comme le type de retour sur lequel nous positionnons l’annotation @Nullable.
@NullUnmarked
Cette annotation permet d’annuler l’effet de l’annotation @NullMarked, elle peut être positionnée sur un module, un package, une classe ou une méthode.
Elle peut être particulièrement utile pour la mise en place progressive de JSpecify dans votre projet. Vous pouvez par exemple annoter @NullUnmarked une classe qui se trouverait dans un package @NullMarked.
Comment le mettre en place ?
Pour commencer, il vous faudra bien entendu ajouter la dépendance JSpecify à votre projet.
Elle est disponible sous le nom : org.jspecify:jspecify:1.0.0.
Pour la suite, il faut être vigilant à ce que la mise en place des annotations ne nuise pas à la lisibilité de votre code.
Pour ce faire, il est recommandé de partir sur un mode de fonctionnement où, par défaut, les types sont considérés comme NonNull.
Et, lorsque les types peuvent effectivement être null, le spécifier explicitement avec l’annotation @Nullable.
On se retrouve alors avec un comportement semblable à ce qui existe dans Kotlin.
Concrètement, vous pouvez, pour chacun de vos packages, définir un fichier package-info.java dans lequel le package est annoté @NullMarked.
@NullMarked
package org.example;
import org.jspecify.annotations.NullMarked;
Puis, lorsqu’un type peut effectivement être null, le spécifier :
interface TokenExtractor {
/**
* Extract a token from a {@link String}.
* @param input the input to process
* @return the extracted token or {@code null} if not found
*/
@Nullable String extractToken(String input);
}
Dans l’exemple ci-dessus, le paramètre input est donc @NonNull grâce au package-info.java bien que non annoté spécifiquement. Nous n’annotons alors explicitement que les types qui peuvent être null, en l’occurrence ici, le type de retour de la méthode.
Aparté sur la cible TYPE_USE d’une annotation
Il peut être intéressant de s’attarder quelques instants sur la cible des annotations @Nullable et @NonNull de JSpecify pour bien comprendre comment les utiliser.
@Documented
@Target({ElementType.TYPE_USE})
@Retention(RetentionPolicy.RUNTIME)
public @interface Nullable {
}
@Documented
@Target({ElementType.TYPE_USE})
@Retention(RetentionPolicy.RUNTIME)
public @interface NonNull {
}
La cible (@Target) pour ces deux annotations est en effet définie à TYPE_USE, autrement dit, l’annotation s’applique à l’utilisation d’un type. Cette cible d’annotation est disponible depuis Java 8.
Si nous reprenons notre exemple :
@Nullable String extractToken(String input);
On n’annote pas la valeur de retour mais le type de la valeur de retour.
S’il est important de préciser ce point, c’est pour mieux comprendre la syntaxe à utiliser dans certains cas.
Par exemple, admettons que notre type de retour soit exprimé sous forme d’un nom qualifié java.lang.String.
La syntaxe :
@Nullable java.lang.String extractToken(String input);
n’est pas valide et doit être écrite ainsi :
java.lang.@Nullable String extractToken(String input);
L’annotation se positionne après le dernier . qui précède le type.
Le deuxième cas subtil est celui de l’utilisation d’un type imbriqué :
Admettons que l’on déclare le type imbriqué Nested dans notre interface TokenExtractor :
interface TokenExtractor {
/**
* Extract a token from a {@link String}.
* @param input the input to process
* @return the extracted token or {@code null} if not found
*/
@Nullable String extractToken(String input);
interface Nested {
}
}
Si l’on souhaite annoter un champ de type Nested, la syntaxe n’est pas :
@Nullable TokenExtractor.Nested nested;
mais :
TokenExtractor.@Nullable Nested nested;
Cette subtilité permet une grande flexibilité dans l’application de ces annotations en fonction de l’usage.
Prenons l’exemple d’une méthode dont le type de retour serait un tableau.
On peut imaginer trois cas :
– Le tableau et ses éléments peuvent être null :
@Nullable String @Nullable[] extractTokens(String input);
– Le tableau peut être null mais pas ses éléments :
@NonNull String @Nullable[] extractTokens(String input);
– Les éléments du tableau peuvent être null mais pas le tableau lui-même :
@Nullable String @NonNull[] extractTokens(String input);
L’utilisation est semblable pour les varargs :
@Nullable String @NonNull… names
Autre cas intéressant, celui des génériques, prenons l’exemple d’un Wrapper<String> :
@NonNull Wrapper<@Nullable String> extractToken(String input);
Définit un type générique Wrapper qui ne peut être null mais dont l’argument de type peut lui être null.
Une meilleure intégration des librairies Java dans un projet Kotlin
On le sait, la Null Safety est native dans le langage Kotlin. Cependant, il arrive que nous ayons besoin d’utiliser des librairies Java depuis notre code Kotlin.
Dans ce cas, Kotlin n’est pas en mesure de savoir si les types issus de cette librairie peuvent être null ou non. Le langage repose donc sur un troisième état qui correspond au fait que la nullabilité n’est pas spécifiée. Cette nullabilité non spécifiée est matérialisée par un type plateforme et le symbole !.
val token : String! = extractor.extractToken(“…”)
Depuis la version 2.1 du compilateur Kotlin, ce dernier comprend les annotations JSpecify et les interprète comme de la Null Safety Kotlin.
Annoter votre librairie Java avec JSpecify rend donc son utilisation plus idiomatique depuis une base de code Kotlin.
Du code non protégé doit casser le build
En mettant en place les annotations JSpecify, vous bénéficiez, lorsque vous écrivez ou naviguez dans votre code, d’alertes si votre IDE constate une utilisation non conforme.
Dans l’exemple ci-dessous, le retour de la méthode extractToken étant défini comme @Nullable, IntelliJ nous avertit que son utilisation sans vérifier au préalable qu’il n’est pas null peut causer une exception.

De même, l’IDE peut nous notifier en cas de vérification inutile, par exemple ici, où le type de retour de la méthode extractToken est défini comme @NotNull.

C’est une bonne chose, mais nous pouvons aller plus loin, en faisant en sorte que ces alertes fassent échouer la construction de notre application.
Pour ce faire, nous pouvons utiliser un outil d’analyse statique de code tel que Error Prone et son plugin NullAway.
Déclarer le plugin Gradle Error Prone :
plugins {
id("net.ltgt.errorprone") version "{{plugin_version}}"
}
Ajouter la dépendance Error Prone :
dependencies {
errorprone("com.google.errorprone:error_prone_core:{{error_prone_version}}")
}
Extension NullAway pour Error Prone :
errorprone "com.uber.nullaway:nullaway:{{NullAway version}}"
Configurer Error Prone :
tasks.withType(JavaCompile).configureEach {
options.errorprone {
disableAllChecks = true
option("NullAway:OnlyNullMarked", "true")
error("NullAway")
}
}
Dans la configuration ci-dessus, nous désactivons toutes les vérifications afin de ne laisser que celles qui concernent NullAway. Nous restreignons également le périmètre de NullAway aux seuls packages qui sont annotés @NullMarked.
Par défaut, NullAway remonte les incohérences sous forme d’avertissements. Ici, nous surchargeons ce mode de fonctionnement pour que des erreurs soient remontées et que la construction de l’application échoue en cas d’incohérence.
Avec la configuration ci-dessus, si l’on tente de manipuler une référence @Nullable sans s’assurer au préalable qu’elle n’est pas null, nous obtenons une erreur comme celle-ci :

Il est à noter que le support des annotations JSpecify par NullAway est pour certains aspects encore en cours de développement. C’est le cas par exemple des tableaux, des méthodes génériques et des types wildcard. Pour bénéficier de la couverture la plus complète possible, il est possible d’activer un mode spécifique (JSpecifyMode) de NullAway.
option("NullAway:JSpecifyMode", "true")
S’agissant d’un travail en cours, il se peut que des faux positifs ou négatifs soient remontés. De plus, l’activation de ce mode nécessite une version récente du compilateur Java (minimum 22).
Un gros chantier en cours sur Spring Framework et Spring Boot
Les équipes de Spring mènent depuis longtemps des travaux autour de la Null Safety et ont accéléré leurs efforts en vue des releases à venir, prévues pour novembre prochain, de Spring Framework 7 et Spring Boot 4.
La mise en place de JSpecify dans ces frameworks a permis de détecter et corriger de nombreux bugs dans l’écosystème Spring.
Il est d’ores et déjà possible de tester l’expérience développeur qui sera offerte en utilisant les versions milestone.
Pour cela, ajoutons par exemple une dépendance sur Spring Web :
implementation('org.springframework:spring-web:7.0.0-M6')
Dans le code :

Nous constatons que l’IDE nous alerte sur le fait que l’invocation de la méthode length sur le type de retour de la méthode body peut produire une NullPointerException. En effet, si nous regardons de plus près la déclaration de la méthode body, nous constatons que le type T est bien défini comme @Nullable.

Fort de cet avertissement, vous pouvez alors décider comment réagir en cas de retour null. La première approche consiste évidemment à gérer explicitement les deux cas null et non null :
if (body != null) {
System.out.println("The body has a length of " + body.length());
} else {
System.out.println("No body found");
}
Une autre option consiste à apporter la garantie à votre IDE et à NullAway que votre élément ne sera jamais null. Pour cela, vous pouvez, par exemple, utiliser la méthode Objects.requireNonNull du JDK :
System.out.println(Objects.requireNonNull(body).length());
Une autre alternative nous est offerte par Spring Framework et sa méthode Assert.state :
Assert.state(body != null, "error message");
System.out.println(body.length());
Cette méthode lève une IllegalArgumentException si l’expression passée en premier paramètre est évaluée à faux. Cette méthode est annotée avec @Contract qui est interprétée par l’IDE et NullAway. Ces derniers peuvent donc considérer qu’après l’appel à cette méthode, body est forcément non null.

Cette annotation est inspirée de celle du même nom portée par JetBrains.
À noter que pour que cette annotation @Contract soit correctement interprétée par NullAway, il est nécessaire d’ajouter une configuration au plugin ErrorProne :
option("NullAway:CustomContractAnnotations", "org.springframework.lang.Contract")
Un cas particulier
Certains usages ne sont actuellement pas pris en charge lorsque l’on utilise JSpecify, en particulier le “chargement paresseux” (Lazy Loading).
Prenons le cas de la classe LazyInitBean suivante définie dans un package annoté @NullMarked.
public class LazyInitBean implements InitializingBean {
private String field;
@Override
public void afterPropertiesSet() throws Exception {
this.field = "value";
}
public String getField() {
return field;
}
}
Notre IDE et NullAway lèveront une erreur sur le champ field car ce dernier n’est pas initialisé. Nous pourrions alors être tentés d’annoter le champ avec @Nullable, mais dans ce cas, nous serions également obligés d’annoter le type de retour de la méthode getField avec @Nullable, ce que nous ne souhaitons pas faire.
Il n’existe pas à ce jour de solution vraiment propre pour adresser cette problématique. La recommandation pour le moment est d’annoter le champ avec @SuppressWarnings :
@SuppressWarnings("NullAway.Init")
private String field;
Cette annotation est supportée par NullAway et devrait bientôt l’être par IntelliJ.
Certains problèmes pour lesquels il n’existe pas encore de solution propre, comme le cas du “chargement paresseux”, pourront peut-être être résolus grâce à une JEP en cours de travail, la JEP 502 sur les Stable Values.
Le principe de ces Stable Values est de bénéficier des mêmes avantages qu’un champ final tout en ayant une initialisation qui est décalée dans le temps.
Un chantier similaire dans le JDK ?
La gestion de la nullabilité étant un aspect auquel la plupart des projets Java sont confrontés, il existe une JEP dont l’objectif est de la rendre explicite nativement.
Cette JEP s’intitule Null-Restricted and Nullable Types, elle a le statut de brouillon à ce jour mais vise à introduire trois états semblables à ce que l’on vient de voir avec JSpecify. La syntaxe proposée diffère un peu de ce que l’on retrouve en Kotlin.
- Foo! représente un type null-restricted, il ne peut pas être null
- Foo? est un type nullable, nous spécifions délibérément la nullabilité
- Foo est non spécifié et correspond à l’état que l’on a déjà aujourd’hui dans Java par défaut
Nous devrions retrouver un niveau de finesse semblable à ce que nous venons de voir avec JSpecify. À titre d’exemple Foo?[]! indiquerait un tableau non null dont les éléments peuvent être null.
Idem pour les génériques avec par exemple Predicate!<Foo?> qui représente un Predicate non null dont les types Foo peuvent être null.
Ce qui est intéressant, c’est que ces informations seraient intégrées au niveau du système de types de Java. Il pourrait donc y avoir des vérifications dans le compilateur et l’information se reflèterait au niveau du bytecode (utilisation au runtime ?).
Un accès plus performant à l’information, complété par les Compact Object Headers, le projet Leyden et les Stable Values permettront d’optimiser la JVM en diminuant la taille de stockage des différentes valeurs en termes de mémoire et de CPU ce qui devrait apporter un gain en termes de performances.
Cette spécification pourrait potentiellement prendre du temps avant d’être finalisée. C’est pourquoi il peut être intéressant de faire un premier pas dans cette direction en mettant en place JSpecify, ce qui devrait permettre une transition vers les types Null-Restricted et Nullable plus aisée.
Devriez-vous introduire JSpecify dans votre projet ?
JSpecify a été livré en version 1.0.0, les équipes garantissent la rétrocompatibilité, il est donc tout à fait possible de l’utiliser de manière pérenne dès maintenant.
Cependant, selon le contexte de votre projet, la mise en place peut être questionnée.
Si votre projet repose déjà sur une des librairies d’expression de la nullabilité et que celle-ci vous apporte pleine satisfaction, alors le bénéfice est certainement très faible. Si ce n’est pas le cas, alors, introduire JSpecify semble tout indiqué.
Quand bien même vous n’ajouteriez que les annotations sans outil d’analyse au build, vous bénéficierez des avantages suivants :
- La documentation de ce qui peut être ou non null dans votre code.
- Les projets qui dépendent de votre code pourront, eux, faire tourner leurs outils de validation s’ils en ont.
- Il vous sera toujours possible d’ajouter une phase de validation au build par la suite.
Si vous souhaitez introduire un outil de validation de la nullabilité à la construction de votre application, il vous faudra vous assurer au préalable de son niveau de maturité. En effet, il existe plusieurs outils supportant les annotations JSpecify, mais tous avec un niveau de conformité différent.
Parmi ces outils, nous retrouvons notamment :
Si vous faites du Kotlin, selon la version du compilateur utilisé, il se peut que le support soit partiel. @Nullable et @NullMarked sont correctement interprétés à partir de la version 1.8.20. @NonNull à partir de la 2.0.0 et @NullUnmarked la 2.0.20.
À partir de la version 2.1.0, le compilateur Kotlin produit des erreurs lorsqu’il détecte un problème en rapport avec la nullabilité exprimée avec JSpecify.
Le mot de la fin
Le bénéfice à rendre la nullabilité explicite dans son code Java ne fait pas trop débat. Aussi, si vous souhaitez la mettre en place, JSpecify est clairement une option à considérer. Les spécifications étant le résultat de plusieurs années de travail concerté entre de grands acteurs de l’écosystème Java, laissent penser que son adoption devrait être large. Les concepts étant semblables à ceux qui devraient à terme voir le jour dans le JDK, introduire JSpecify aujourd’hui semble être une bonne première étape avant de basculer sur le futur mécanisme de NullSafety natif à Java.
Pour aller plus loin :
- La captation de la présentation de Sébastien Deleuze à MIXIT
- Le repository Github associé à la présentation
- Le site officiel de JSpecify
- NullAway
- NullAway JSpecify support
- Null Safety in Spring applications with JSpecify and NullAway
- Article InfoQ lors de la release 1.0.0 de JSpecify
- JSR 308 Explained: Java Type Annotations
- [StackOverflow] – Which @NotNull Java annotation should I use?
De la NullSafety Java à MIXIT was originally published in ekino-france on Medium, where people are continuing the conversation by highlighting and responding to this story.
