Salut ! Aujourd'hui, on va parler d'une des fonctionnalités les plus importantes de Dart moderne : le null safety. Si tu as déjà eu une erreur "null reference exception" qui a fait crasher ton app en production (et qui ne l'a pas eu ?), tu vas adorer cet article.
Le null safety, c'est pas juste une fonctionnalité supplémentaire de Dart - c'est une révolution dans la façon dont on gère les valeurs potentiellement absentes. Ça élimine toute une catégorie d'erreurs à la compilation plutôt qu'au runtime.
Dans cet article, je vais t'expliquer en profondeur comment fonctionne le null safety, pourquoi c'est si important, et comment l'utiliser efficacement dans tes projets. À la fin, tu sauras écrire du code Dart qui ne crashera jamais à cause d'un null !
Prêt à dire adieu aux null pointer exceptions ? Let's go ! 🚀
Le problème du "billion-dollar mistake"
Avant de plonger dans le null safety de Dart, comprenons pourquoi c'est un problème si important.
L'histoire de null
Tony Hoare, l'inventeur de la référence null, l'a appelé son "erreur à un milliard de dollars". Pourquoi ? Parce que null a causé d'innombrables bugs, crashs et vulnérabilités de sécurité depuis sa création.
Le problème fondamental est simple : dans la plupart des langages, n'importe quelle variable peut être null sans prévenir. Tu penses avoir une String ? Surprise, c'est null ! Et boom, ton app crash.
// Sans null safety (ancien Dart)
String nom = obtenirNom(); // Peut retourner null
print(nom.length); // CRASH si nom est null !
Comment les autres langages gèrent ça
Différents langages ont essayé différentes approches :
Java/JavaScript : Tout peut être null, bonne chance ! Tu dois vérifier manuellement partout.
Kotlin/Swift : Ont introduit des types optionnels (String?), mais après coup, donc beaucoup de code legacy reste vulnérable.
Rust : Pas de null du tout ! Utilise Option<T>, très sûr mais peut être verbeux.
Dart 2.12+ : Null safety intégré au système de types avec vérification statique. Le meilleur des mondes !
Null Safety : les concepts fondamentaux
Le null safety de Dart repose sur une idée simple mais puissante : par défaut, les variables ne peuvent PAS être null.
Types non-nullable vs nullable
Avec le null safety, Dart distingue clairement deux catégories de types :
Types non-nullable : Ne peuvent jamais être null
String nom = 'Alice';
int age = 25;
bool estActif = true;
// nom = null; // ERREUR DE COMPILATION ✗
Types nullable : Peuvent être null (marqués avec ?)
String? nomOptional; // Peut être null
int? ageOptional; // Peut être null
bool? estActifOptional; // Peut être null
nomOptional = null; // OK ✓
nomOptional = 'Bob'; // OK aussi ✓
C'est un changement de paradigme majeur. Avant, tu devais supposer que tout pouvait être null et vérifier partout. Maintenant, tu sais exactement quelles variables peuvent être null rien qu'en regardant leur type.
Pourquoi c'est révolutionnaire
Imagine que tu as une fonction qui retourne un utilisateur :
// Sans null safety - tu ne sais pas si ça peut être null
User getUser(int id) {
// Peut retourner null ? Qui sait...
}
// Avec null safety - c'est explicite !
User getNonNullUser(int id) {
// Garantit de retourner un User, jamais null
}
User? getMaybeUser(int id) {
// Peut retourner null, c'est clair dans la signature
}
En regardant juste la signature de la fonction, tu sais immédiatement si tu dois gérer le cas null ou pas. Le compilateur te force à gérer tous les cas, donc pas de surprises au runtime !
Les trois piliers du null safety
Le système de null safety de Dart repose sur trois garanties fondamentales :
1. Les variables non-nullable doivent être initialisées
Une variable non-nullable ne peut jamais contenir null, donc elle doit avoir une valeur dès sa déclaration.
// ✗ ERREUR : variable non-nullable non initialisée
String nom;
print(nom); // Le compilateur refuse
// ✓ OK : initialisée immédiatement
String nom = 'Alice';
// ✓ OK : initialisée dans le constructeur
class Utilisateur {
final String nom;
final int age;
Utilisateur(this.nom, this.age); // nom et age sont garantis initialisés
}
// ✗ ERREUR : pas initialisé dans le constructeur
class Utilisateur2 {
final String nom;
Utilisateur2(); // Oups, nom n'est pas initialisé !
}
Cette règle élimine toute une catégorie d'erreurs où tu accèdes à une variable avant de l'avoir initialisée.
2. Les variables nullable doivent être vérifiées avant utilisation
Si une variable peut être null, le compilateur te force à gérer ce cas avant de l'utiliser.
String? nom = obtenirNom(); // Peut être null
// ✗ ERREUR : utilisation directe
print(nom.length); // Le compilateur refuse
// ✓ OK : vérification avec if
if (nom != null) {
print(nom.length); // Ici, le compilateur sait que nom n'est pas null
}
// ✓ OK : utilisation de l'opérateur ?.
print(nom?.length); // Retourne null si nom est null
// ✓ OK : valeur par défaut
int longueur = nom?.length ?? 0;
Le compilateur suit le "flow" de ton code et comprend quand une variable ne peut plus être null. C'est ce qu'on appelle le flow analysis.
3. Les types sont distincts
String et String? sont deux types différents et incompatibles.
String nom = 'Alice';
String? nomOptional = 'Bob';
// ✗ ERREUR : ne peut pas assigner String? à String
nom = nomOptional;
// ✓ OK : vérification explicite nécessaire
if (nomOptional != null) {
nom = nomOptional; // OK car vérifié
}
// ✓ OK : assertion (utilise avec précaution)
nom = nomOptional!; // ! dit "je garantis que c'est pas null"
Flow Analysis : le compilateur qui comprend ton code
Une des parties les plus impressionnantes du null safety de Dart, c'est que le compilateur est intelligent. Il suit le flux d'exécution de ton code et comprend quand une variable ne peut plus être null.
Exemple simple
String? nom = obtenirNom();
// Ici, nom peut être null
print(nom?.length); // Utilisation safe
if (nom != null) {
// Ici, le compilateur sait que nom n'est PAS null
print(nom.length); // OK, pas besoin de ?
print(nom.toUpperCase()); // OK
String copie = nom; // OK, peut assigner à String non-nullable
}
// Ici, nom peut être null à nouveau
print(nom?.length); // Besoin du ? à nouveau
Le compilateur "promote" automatiquement String? en String dans le bloc if parce qu'il a vérifié que ce n'est pas null.
Flow analysis avancé
Le compilateur comprend plusieurs patterns :
String? texte = obtenirTexte();
// Pattern 1 : return early
if (texte == null) {
return; // ou throw, ou continue, ou break
}
// Après le if, texte est forcément non-null
print(texte.length); // OK
// Pattern 2 : opérateur ternaire
String valeur = texte == null ? 'défaut' : texte.toUpperCase();
// Pattern 3 : ?? avec assignment
texte ??= 'valeur par défaut';
print(texte.length); // OK car texte a forcément une valeur maintenant
Cas où le flow analysis ne marche pas
Il y a des limites. Le compilateur ne peut pas analyser certaines situations complexes :
class Container {
String? valeur;
}
void exemple() {
Container container = Container();
container.valeur = 'Hello';
if (container.valeur != null) {
// ✗ ERREUR : container.valeur pourrait avoir changé
print(container.valeur.length);
}
}
Pourquoi ? Parce que container.valeur est un field mutable. Entre le check et l'utilisation, une autre partie du code pourrait l'avoir modifié. Pour régler ça :
void exemple() {
Container container = Container();
container.valeur = 'Hello';
// Solution 1 : stocker dans une variable locale
final valeur = container.valeur;
if (valeur != null) {
print(valeur.length); // OK
}
// Solution 2 : utiliser ?. et ??
print(container.valeur?.length ?? 0);
}
Les opérateurs du null safety
Dart fournit plusieurs opérateurs spécialement conçus pour travailler avec des valeurs nullables.
L'opérateur ?. (null-aware access)
Accède à un membre seulement si la valeur n'est pas null, sinon retourne null.
String? nom = obtenirNom();
// Sans ?.
String? maj;
if (nom != null) {
maj = nom.toUpperCase();
} else {
maj = null;
}
// Avec ?. (beaucoup plus concis)
String? maj = nom?.toUpperCase();
Tu peux chaîner les appels :
class Adresse {
String? ville;
}
class Utilisateur {
Adresse? adresse;
}
Utilisateur? user = obtenirUtilisateur();
// Sans ?.
String? ville;
if (user != null && user.adresse != null) {
ville = user.adresse!.ville;
}
// Avec ?. (élégant)
String? ville = user?.adresse?.ville;
L'opérateur ?? (null coalescing)
Fournit une valeur par défaut si la valeur est null.
String? nom = obtenirNom();
// Utiliser 'Anonyme' si nom est null
String affichage = nom ?? 'Anonyme';
// Équivalent à :
String affichage = nom != null ? nom : 'Anonyme';
Très utile pour les configurations avec valeurs par défaut :
class Configuration {
final int timeout;
final String theme;
final bool debugMode;
Configuration({
int? timeout,
String? theme,
bool? debugMode,
}) :
timeout = timeout ?? 30,
theme = theme ?? 'light',
debugMode = debugMode ?? false;
}
// Utilisation
var config1 = Configuration(); // Toutes les valeurs par défaut
var config2 = Configuration(timeout: 60, theme: 'dark');
L'opérateur ??= (null-aware assignment)
Assigne une valeur seulement si la variable actuelle est null.
String? nom;
nom ??= 'Valeur par défaut';
print(nom); // 'Valeur par défaut'
nom ??= 'Autre valeur';
print(nom); // Toujours 'Valeur par défaut' (pas changé)
// Équivalent à :
if (nom == null) {
nom = 'Valeur par défaut';
}
Pratique pour l'initialisation lazy :
class Cache {
Map<String, String>? _data;
Map<String, String> get data {
// Initialiser seulement au premier accès
return _data ??= {};
}
}
L'opérateur ! (null assertion)
Force le compilateur à traiter une valeur nullable comme non-nullable. Utilise avec précaution !
String? nom = obtenirNom();
// Si tu es ABSOLUMENT SÛR que nom n'est pas null
String definitif = nom!;
// DANGER : si nom est null, CRASH runtime !
L'opérateur ! est un "échappatoire". Tu dis au compilateur : "Fais-moi confiance, je sais ce que je fais". Si tu te trompes, ton app crashera.
Quand l'utiliser ?
-
Quand tu as vérifié manuellement qu'une valeur n'est pas null
-
Quand tu as une connaissance externe que le compilateur ne peut pas vérifier
-
Dans les tests (où un crash est acceptable)
Quand l'éviter ?
- Partout ailleurs ! Préfère
?.,??, ou des vérifications explicites
// ✗ Mauvais : risque de crash
void mauvais(String? nom) {
print(nom!.length); // DANGER
}
// ✓ Bon : gestion sûre
void bon(String? nom) {
if (nom != null) {
print(nom.length);
} else {
print('Nom non fourni');
}
}
// ✓ Bon aussi : valeur par défaut
void bonAussi(String? nom) {
print((nom ?? 'Anonyme').length);
}
Le mot-clé late
late est un modificateur spécial qui dit : "Cette variable sera initialisée plus tard, mais avant d'être utilisée."
Cas d'usage 1 : Initialisation différée
Parfois, tu ne peux pas initialiser une variable immédiatement, mais tu sais qu'elle sera initialisée avant utilisation.
class Utilisateur {
late String nom;
late int age;
Utilisateur() {
// Initialisation complexe
chargerDonnees();
}
void chargerDonnees() {
// Récupérer depuis une DB, API, etc.
nom = 'Alice';
age = 25;
}
void afficher() {
print('$nom, $age ans'); // OK car initialisées dans chargerDonnees
}
}
Attention : Si tu accèdes à une variable late avant de l'initialiser, crash runtime !
class Exemple {
late String valeur;
void mauvais() {
print(valeur); // CRASH : LateInitializationError
}
void bon() {
valeur = 'Initialisé';
print(valeur); // OK
}
}
Cas d'usage 2 : Lazy initialization
late peut aussi être utilisé avec une initialisation qui ne s'exécute que lors du premier accès.
class DataService {
// Créée seulement quand on y accède
late final List<String> _donnees = _chargerDonnees();
List<String> _chargerDonnees() {
print('Chargement des données...');
return ['A', 'B', 'C'];
}
List<String> get donnees => _donnees;
}
void main() {
var service = DataService();
print('Service créé'); // _donnees pas encore chargées
print(service.donnees); // Maintenant _chargerDonnees() est appelée
// Output:
// Service créé
// Chargement des données...
// [A, B, C]
print(service.donnees); // Pas de rechargement, déjà initialisé
}
C'est parfait pour les ressources coûteuses qu'on ne veut charger qu'au besoin.
late final : combinaison puissante
class Configuration {
// Sera initialisée une fois, puis immutable
late final String apiKey;
late final int timeout;
Configuration() {
_chargerConfig();
}
void _chargerConfig() {
// Charger depuis un fichier, variables d'environnement, etc.
apiKey = 'abc123';
timeout = 30;
// apiKey = 'autre'; // ERREUR : late final ne peut être assigné qu'une fois
}
}
Patterns avancés
Maintenant qu'on a les bases, explorons des patterns plus avancés pour gérer le null de façon élégante.
Pattern 1 : Early return
Plutôt que d'imbriquer des if, retourne tôt en cas de null.
// ✗ Mauvais : imbrication profonde
void traiterUtilisateur(Utilisateur? user) {
if (user != null) {
if (user.email != null) {
if (user.email!.contains('@')) {
envoyerEmail(user.email!);
}
}
}
}
// ✓ Bon : early returns
void traiterUtilisateur(Utilisateur? user) {
if (user == null) return;
final email = user.email;
if (email == null) return;
if (!email.contains('@')) return;
envoyerEmail(email);
}
Pattern 2 : Extension methods pour null safety
Créer des extensions pour gérer les cas null de façon réutilisable.
extension StringExtensions on String? {
// Vérifier si non-null et non-vide
bool get isNotNullOrEmpty {
return this != null && this!.isNotEmpty;
}
// Obtenir la longueur de façon safe
int get safeLength {
return this?.length ?? 0;
}
// Capitaliser de façon safe
String? capitalizeFirst() {
final text = this;
if (text == null || text.isEmpty) return null;
return text[0].toUpperCase() + text.substring(1);
}
}
// Utilisation
String? nom = obtenirNom();
if (nom.isNotNullOrEmpty) {
print(nom!.toUpperCase());
}
print('Longueur: ${nom.safeLength}');
print('Capitalisé: ${nom.capitalizeFirst() ?? "N/A"}');
Pattern 3 : Result type pour gestion d'erreur
Au lieu de retourner null pour indiquer une erreur, utilise un type Result.
// Type Result générique
class Result<T> {
final T? data;
final String? error;
Result.success(this.data) : error = null;
Result.failure(this.error) : data = null;
bool get isSuccess => data != null;
bool get isFailure => error != null;
}
// Utilisation
Result<Utilisateur> chargerUtilisateur(int id) {
try {
// Simuler un chargement
if (id < 0) {
return Result.failure('ID invalide');
}
final user = Utilisateur(id: id, nom: 'Alice');
return Result.success(user);
} catch (e) {
return Result.failure(e.toString());
}
}
void exemple() {
final result = chargerUtilisateur(1);
if (result.isSuccess) {
print('Utilisateur: ${result.data!.nom}');
} else {
print('Erreur: ${result.error}');
}
}
C'est plus explicite que retourner null et permet de transporter une information d'erreur.
Pattern 4 : Nullable avec valeur par défaut dans paramètres
class Utilisateur {
final String nom;
final int age;
final String? ville; // Vraiment optionnel
Utilisateur({
required this.nom,
int? age, // Optionnel avec défaut
this.ville, // Optionnel sans défaut
}) : age = age ?? 18;
@override
String toString() {
final villeText = ville != null ? ' de $ville' : '';
return '$nom, $age ans$villeText';
}
}
// Utilisation
var user1 = Utilisateur(nom: 'Alice');
// Alice, 18 ans
var user2 = Utilisateur(nom: 'Bob', age: 30, ville: 'Paris');
// Bob, 30 ans de Paris
Migrer vers null safety
Si tu as du vieux code Dart sans null safety, voici comment migrer.
Étape 1 : Analyse
# Vérifier si ton code est prêt
dart migrate --dry-run
Étape 2 : Migration interactive
# Lancer la migration avec interface web
dart migrate
L'outil te montrera tous les changements proposés et tu pourras les accepter ou les modifier.
Étape 3 : Corrections manuelles
Après la migration automatique, tu devras probablement faire des ajustements :
// Avant migration
String obtenirNom() {
return null; // Pouvait retourner null
}
// Après migration automatique
String? obtenirNom() {
return null;
}
// Correction : décider si null est vraiment nécessaire
String obtenirNomAvecDefaut() {
return 'Anonyme'; // Pas de null, meilleur design
}
String? obtenirNomOptional() {
return null; // Garde null seulement si nécessaire
}
Bonnes pratiques
Terminons avec des conseils pour bien utiliser le null safety.
1. Préfère non-nullable par défaut
// ✗ Mauvais : tout est nullable
class Utilisateur {
String? nom;
int? age;
String? email;
}
// ✓ Bon : seulement ce qui peut vraiment être absent
class Utilisateur {
final String nom; // Toujours présent
final int age; // Toujours présent
final String? email; // Vraiment optionnel
Utilisateur({
required this.nom,
required this.age,
this.email,
});
}
2. Utilise des valeurs par défaut plutôt que null
// ✗ Mauvais
String? obtenirTheme() {
return null; // Utilisateur n'a pas spécifié
}
void appliquerTheme() {
String theme = obtenirTheme() ?? 'light';
// ...
}
// ✓ Bon
String obtenirTheme() {
return 'light'; // Valeur par défaut directement
}
void appliquerTheme() {
String theme = obtenirTheme();
// ...
}
3. Évite ! autant que possible
// ✗ Risqué
void afficher(String? texte) {
print(texte!.length); // Peut crasher
}
// ✓ Sûr
void afficher(String? texte) {
if (texte != null) {
print(texte.length);
} else {
print('Texte manquant');
}
}
// ✓ Aussi sûr et concis
void afficherConcis(String? texte) {
print(texte?.length ?? 0);
}
4. Utilise late seulement quand nécessaire
// ✗ Mauvais : late utilisé pour éviter l'initialisation
class Mauvais {
late String nom;
// Oups, oubli d'initialiser nom
void afficher() {
print(nom); // CRASH
}
}
// ✓ Bon : initialisation claire
class Bon {
String nom;
Bon(this.nom);
void afficher() {
print(nom); // Toujours sûr
}
}
5. Documente les raisons des nullables
class Utilisateur {
final String nom;
/// Email optionnel car certains utilisateurs utilisent
/// l'authentification par téléphone uniquement
final String? email;
/// Photo de profil optionnelle - null jusqu'à ce que
/// l'utilisateur en upload une
final String? photoUrl;
Utilisateur({
required this.nom,
this.email,
this.photoUrl,
});
}
Conclusion
Le null safety est une des meilleures fonctionnalités de Dart. Il élimine toute une catégorie d'erreurs et rend ton code plus robuste et plus facile à comprendre.
Points clés à retenir :
-
Types non-nullable par défaut : Une variable ne peut être null que si tu le marques avec
? -
Flow analysis intelligent : Le compilateur comprend ton code et fait la promotion de types
-
Opérateurs dédiés :
?.,??,??=,!pour gérer le null élégamment -
late pour initialisation différée : Mais utilise avec précaution
-
Préfère non-nullable : Rends nullable seulement ce qui doit vraiment l'être
Bonnes pratiques :
-
Évite
!autant que possible -
Utilise des valeurs par défaut plutôt que null
-
Documente pourquoi quelque chose est nullable
-
Préfère early returns aux imbrications de if
Avec le null safety, tu peux écrire du code Dart confiant, sachant que le compilateur attrape les erreurs de null avant qu'elles n'atteignent tes utilisateurs !
Ressources pour aller plus loin :
Tu as des questions sur le null safety ? Partage-les dans les commentaires !
Articles connexes :

Laisser un commentaire