Null Safety en Kotlin : ?, !!, let et Elvis Operator
1. Aperçu
NullPointerException (NPE) est le cauchemar de tout dev Java. Kotlin résout ce problème dès la compile-time grâce à un système de null safety intégré au type system. Cet article vous aide à comprendre les quatre outils essentiels : le signe ? pour déclarer un type nullable, le safe call ?., l’elvis operator ?:, et la fonction let. À la fin, vous saurez quand utiliser quel outil et vous éviterez le piège classique : utiliser !! partout et avoir quand même des NPE.
2. Type Nullable et le signe ?
Dans Kotlin, tout type est non-null par défaut. Si vous voulez qu’une variable puisse contenir null, vous devez le déclarer explicitement avec le signe ? après le type.
class Customer(val name: String, val email: String?)
fun main() {
val customer = Customer("An", null)
println(customer.email) // null, sans erreur car email est String?
}
name: String— ne pourra jamais êtrenull, le compilateur le garantit.email: String?— peut êtrenull, le compilateur vous oblige à le gérer partout où vous l’utilisez.
Si vous essayez d’appeler une méthode directement sur email comme customer.email.length, le compilateur va signaler une erreur immédiatement :
error: only safe (?.) or non-null asserted (!!.) calls are allowed on a nullable receiver of type String?
Voilà l’avantage majeur : les erreurs sont détectées lors de la compilation, pas quand l’app tourne en production.
3. Safe Call ?.
?. appelle une méthode ou accède à une propriété seulement si le receiver n’est pas null. Si le receiver est null, toute l’expression retourne null directement, sans lancer d’exception.
class Order(val id: Long, val customer: Customer?)
fun printCustomerName(order: Order) {
val length = order.customer?.name?.length
println("Longueur du nom : $length")
}
order.customer?.name— sicustomerestnull, l’expression entière court-circuite et retournenull, sans avoir besoin d’exécuter.length.- Le résultat
lengthest de typeInt?puisque chaque étape dans la chaîne?.peut retournernull.
Longueur du nom : null // quand customer == null
Longueur du nom : 2 // quand customer.name = "An"
Note : Une chaîne
?.de plus de 3-4 étapes est généralement un signe que votre modèle de domaine n’est pas bien conçu. Pensez à factoriser en fonctions ou utiliser des sealed classes plutôt que de laisser les nullable se propager partout.
3.1. Safe Call combiné avec les appels de fonction
?. fonctionne aussi avec les fonctions, pas seulement avec les propriétés :
fun notifyCustomer(order: Order) {
order.customer?.let { sendEmail(it) }
}
fun sendEmail(customer: Customer) {
println("Envoi d'un e-mail à ${customer.email}")
}
La ligne order.customer?.let { ... } exécute le bloc let seulement si customer n’est pas null. Cela est directement lié à la section let à la partie 5.
4. Elvis Operator ?:
L’Elvis operator fournit une valeur par défaut quand l’expression de gauche est null. Le nom vient de la forme ?: qui ressemble aux cheveux d’Elvis Presley vus de côté.
fun getDisplayEmail(customer: Customer): String {
return customer.email ?: "email non fourni"
}
- Si
customer.email != null, retourne cette valeur elle-même. - S’il est
null, retourne la chaîne de secours"email non fourni".
L’Elvis operator est encore plus puissant quand combiné avec return ou throw, permettant une sortie anticipée du code de façon élégante :
fun validateOrder(order: Order): Customer {
val customer = order.customer ?: throw IllegalStateException("Commande ${order.id} sans customer")
return customer
}
La ligne ?: throw IllegalStateException(...) fait que customer ensuite est inféré par le compilateur comme de type non-null Customer, pas Customer?. C’est une technique très courante pour « déemballer » les nullable tôt dans une fonction.
4.1. Elvis avec fonction par défaut sûre
fun calculateDiscount(order: Order): Double {
val tier = order.customer?.name?.length ?: 0
return if (tier > 5) 0.1 else 0.0
}
Ici, la chaîne de safe call et l’elvis se combinent : si n’importe quelle étape retourne null, le résultat final bascule sur 0.
5. Fonction let pour traiter les valeurs non-null
let est une scope function : elle reçoit un receiver, exécute un bloc, retourne le résultat du bloc. Combinée avec ?., elle devient un pattern classique pour n’exécuter du code que quand la valeur n’est pas null.
fun processPayment(amount: Double?) {
amount?.let {
println("Traitement du paiement : $it")
chargeCard(it)
}
}
fun chargeCard(value: Double) {
println("Débité $value de la carte")
}
amount?.let { ... }— le bloc interne s’exécute seulement siamount != null.- À l’intérieur du bloc,
itest de typeDouble(non-null), pasDouble?, donc vous appelezchargeCard(it)sans souci, sans avoir besoin de!!ou de vérification supplémentaire.
Par rapport à écrire if (amount != null) { ... }, utiliser let aide à limiter la portée de la variable temporaire, évite que la variable ne s’échappe du bloc et rend le code plus « pipeline ».
5.1. Combiner let avec Elvis pour retourner une valeur
fun formatAmount(amount: Double?): String {
return amount?.let { "Montant : %.2f".format(it) } ?: "Aucun montant fourni"
}
Cette ligne se lit naturellement : « s’il y a amount alors format, sinon retourne le fallback ». C’est un idiome très courant dans le code Kotlin réel, particulièrement quand on mappe les données des DTO vers le modèle de domaine.
6. Non-null Assertion !! — Quand l’utiliser ?
!! force le compilateur à traiter un type nullable comme non-null. Si la valeur réelle est null, cela lance une NullPointerException immédiatement.
fun getCustomerNameUnsafe(order: Order): String {
return order.customer!!.name
}
order.customer!!— sicustomerestnull, cette ligne lève une NPE avec un message assez clair :
Exception in thread "main" java.lang.NullPointerException
at MainKt.getCustomerNameUnsafe(Main.kt:2)
Note :
!!c’est essentiellement vous qui ramenez manuellement les NPE de Java dans Kotlin. Utilisez-le seulement quand vous êtes 100% certain que la valeur n’est pas null à ce moment-là (par exemple après une validation à l’étape précédente), et de préférence avec un commentaire expliquant pourquoi.
Quand c’est acceptable d’utiliser !! :
- Interop avec du code Java ancien, API retourne
@Nullablemais vous savez que la logique métier garantit non-null. - Code de test, quand vous voulez échouer rapidement si le setup est mauvais.
Quand on ne doit pas utiliser !! :
- Traiter l’input utilisateur, les requêtes HTTP, la base de données — cela peut toujours devenir null de façon inattendue.
- Substitut à la logique de validation. Préférez
requireNotNull()ou elvis + exception plus explicite.
fun getCustomerName(order: Order): String {
val customer = requireNotNull(order.customer) { "Commande ${order.id} doit avoir un customer" }
return customer.name
}
requireNotNull à la fois déemballe le nullable et fournit un message d’erreur bien plus clair que !! — c’est le choix à préférer plutôt que !! brut.
7. Comparaison rapide des quatre outils
| Outil | Quand l’utiliser | Comportement avec null |
|---|---|---|
? |
Déclarer un type qui peut être null | N/A, c’est juste une annotation de type |
?. |
Accès sûr, non assuré de ne pas être null | Retourne null, sans lancer |
?: |
Besoin de valeur fallback ou early-return | Exécute l’expression de droite |
let |
Exécuter la logique seulement quand il y a une valeur | N’exécute pas le bloc |
!! |
100% certain que non-null | Lance NullPointerException |
8. Écrire des tests pour les fonctions gérant les null
Pour s’assurer que la logique null-safety fonctionne correctement, écrivez des tests pour les deux branches : avec valeur et null.
import org.junit.jupiter.api.Test
import org.assertj.core.api.Assertions.assertThat
import org.assertj.core.api.Assertions.assertThatThrownBy
class OrderServiceTest {
@Test
fun `getDisplayEmail retourne fallback quand email est null`() {
val customer = Customer(name = "An", email = null)
val result = getDisplayEmail(customer)
assertThat(result).isEqualTo("email non fourni")
}
@Test
fun `getDisplayEmail retourne email quand il y a une valeur`() {
val customer = Customer(name = "An", email = "an@example.com")
val result = getDisplayEmail(customer)
assertThat(result).isEqualTo("an@example.com")
}
@Test
fun `validateOrder lance quand customer est null`() {
val order = Order(id = 1L, customer = null)
assertThatThrownBy { validateOrder(order) }
.isInstanceOf(IllegalStateException::class.java)
.hasMessageContaining("sans customer")
}
}
- Le premier test vérifie que elvis retourne le fallback correctement quand
email == null. - Le deuxième test vérifie la branche opposée — la valeur réelle n’est pas perdue.
- Le dernier test vérifie que
?: throwlève la bonne exception avec le message attendu, en utilisantassertThatThrownByd’AssertJ.
Dépendances nécessaires pour les tests (si vous utilisez Gradle Kotlin DSL) :
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.10.2</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>3.25.3</version>
<scope>test</scope>
</dependency>
9. Erreurs courantes
Abuser de !! pour « faire taire le compilateur ». Quand vous voyez le compilateur signaler une erreur nullable et que votre réflexe est de taper !! immédiatement, arrêtez — c’est un signe que vous n’avez pas bien compris le flux de données. Posez-vous la question : cette valeur peut-elle réellement être null ? Si oui, gérez-la avec ?: ou let.
Utiliser ?.let au lieu de if-else mécaniquement. Avec une logique complexe ayant plusieurs branches, if (x != null) { } else { } est parfois plus clair que des let imbriqués. Ne forcez pas tout dans elvis/let si ça rend le code plus difficile à lire.
Oublier que les types platform venant de Java n’ont pas de null safety. Quand vous appelez une méthode Java retournant un object sans annotation @Nullable/@NonNull, Kotlin le traite comme un type platform (String!), le compilateur ne vous oblige pas à vérifier null. Les NPE peuvent toujours survenir à runtime dans ce cas — ajoutez manuellement ?. ou vérifiez vous-même en travaillant avec Java interop.
// Java: public String getLegacyName() { return null; } // pas d'annotation
val name = legacyService.getLegacyName() // le type est inféré comme String!, Kotlin n'avertit pas
println(name.length) // peut être NPE à runtime si name est null
10. Conclusion
Kotlin’s null safety n’est pas de la « magie » éliminant complètement les NPE, mais un outil pour détecter les erreurs plus tôt — à la compile-time plutôt qu’à la runtime. Le signe ? déclare la possibilité d’une valeur null, ?. pour accès sûr, ?: fournit fallback ou early-return, let limite la portée de la logique quand il y a une valeur. Et !! est l’échappatoire d’urgence, ne l’utilisez que si vous êtes certain et prêt à accepter une NPE si vous avez tort. Maîtriser ces quatre outils vous permettra d’écrire du code Kotlin beaucoup plus sûr que les vieilles habitudes de vérification de null à la Java.