La NullReferenceException reste l’exception la plus fréquente en production .NET. Depuis C# 8, les nullable reference types (NRT) permettent au compilateur de suivre la nullabilité de chaque référence et de signaler, à la compilation, les déréférencements dangereux. Cet article détaille le fonctionnement de l’analyse de flux, les annotations (?, !, attributs), les cas des génériques et de required, la migration d’un projet existant, et l’interaction avec EF Core et System.Text.Json.
1. Le principe : rendre l’intention explicite
Avant C# 8, un string pouvait toujours valoir null : le type ne disait rien de l’intention. Avec les NRT activés, la distinction devient explicite :
string: référence non nullable, censée ne jamais valoirnull;string?: référence nullable, qu’il faut tester avant usage.
Point essentiel : contrairement à int? (qui est un vrai type Nullable<int>), string? n’existe pas à l’exécution. C’est une annotation lue par le compilateur, matérialisée dans les métadonnées par des attributs ([Nullable], [NullableContext]). Aucun contrôle n’est ajouté au runtime : il s’agit d’une analyse statique, qui produit des warnings, pas des erreurs.
Activation
Les templates dotnet new activent la fonctionnalité par défaut depuis .NET 6. Dans le .csproj :
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
</PropertyGroup>
La propriété Nullable accepte quatre valeurs :
| Valeur | Annotations (?) |
Warnings |
|---|---|---|
disable |
ignorées (comportement historique) | non |
enable |
actives | oui |
annotations |
actives | non |
warnings |
non (tout est « oblivious ») | oui |
2. L’analyse de flux : maybe-null et not-null
Le compilateur attribue à chaque expression un état de nullabilité (null-state) : not-null ou maybe-null. Cet état évolue au fil du code, en fonction des affectations et des tests.
string? GetNickname(int userId) => userId == 42 ? "Neo" : null;
void Display(int userId)
{
string? nickname = GetNickname(userId); // maybe-null
// ❌ CS8602 : déréférencement d'une référence potentiellement null
Console.WriteLine(nickname.Length);
if (nickname is null)
return;
// ✅ ici nickname est not-null : plus de warning
Console.WriteLine(nickname.Length);
nickname = null; // redevient maybe-null
nickname ??= "Anonyme"; // not-null à nouveau
Console.WriteLine(nickname.ToUpperInvariant());
}
stateDiagram-v2
state "maybe-null" as MaybeNull
state "not-null" as NotNull
[*] --> MaybeNull : variable string?<br/>ou appel retournant T?
[*] --> NotNull : new(), littéral,<br/>retour non nullable
MaybeNull --> NotNull : is not null / != null<br/>?? / ??=<br/>ThrowIfNull
NotNull --> MaybeNull : affectation de null<br/>ou d'une valeur maybe-null
MaybeNull --> MaybeNull : déréférencement<br/>⚠ CS8602
NotNull --> NotNull : déréférencement OK
Les principaux warnings à connaître :
| Code | Signification |
|---|---|
| CS8600 | Conversion d’une valeur possiblement null vers un type non nullable |
| CS8602 | Déréférencement d’une référence possiblement null |
| CS8603 | Retour possiblement null pour un type de retour non nullable |
| CS8604 | Argument possiblement null passé à un paramètre non nullable |
| CS8618 | Champ ou propriété non nullable non initialisé en sortie de constructeur |
| CS8625 | Littéral null converti en type non nullable |
Les limites de l’analyse
L’analyse est volontairement pragmatique, pas exhaustive :
if (order.Customer is not null)
{
RecalculerRemises(order); // pourrait remettre Customer à null...
Console.WriteLine(order.Customer.Name); // ...mais aucun warning
}
var names = new string[10]; // dix éléments null, aucun warning
string first = names[0]; // null à l'exécution malgré le type non nullable
Le compilateur ne suit pas les effets de bord des appels de méthode, ni le contenu des tableaux, ni les default de structs contenant des références. Les NRT réduisent fortement les NullReferenceException, sans les éliminer totalement.
3. L’opérateur ! (null-forgiving) et pourquoi l’éviter
Le suffixe ! dit au compilateur : « fais-moi confiance, cette valeur n’est pas null ». Il fait passer l’état à not-null sans aucune vérification.
// ❌ On fait taire le compilateur ; si FindUser renvoie null, crash plus loin
User user = repository.FindUser(id)!;
SendEmail(user.Email);
// ✅ On traite réellement le cas null
User user = repository.FindUser(id)
?? throw new NotFoundException($"Utilisateur {id} introuvable");
Chaque ! est une dette : il masque exactement l’erreur que la fonctionnalité doit détecter. Il reste acceptable dans quelques cas ciblés :
- propriétés initialisées par un framework (navigations EF Core, voir plus bas) ;
- tests unitaires, pour vérifier volontairement le comportement avec
null(new Service(null!)) ; - code dont l’invariant est garanti mais que l’analyse ne peut pas prouver, avec un commentaire.
Dans un code métier, un ! doit être justifié en revue de code. La forme null! en initialisation de propriété est particulièrement dangereuse : elle désactive CS8618 sans rien garantir.
4. Les attributs d’analyse
Le ? ne suffit pas à décrire toutes les API. Le namespace System.Diagnostics.CodeAnalysis fournit des attributs qui affinent le contrat.
Conditionnels : [NotNullWhen] et [MaybeNullWhen]
Le cas d’école est le pattern TryXxx :
using System.Diagnostics.CodeAnalysis;
public bool TryGetCustomer(int id, [NotNullWhen(true)] out Customer? customer)
{
customer = _cache.GetValueOrDefault(id);
return customer is not null;
}
if (TryGetCustomer(42, out var customer))
{
Console.WriteLine(customer.Name); // ✅ not-null quand le retour est true
}
string.IsNullOrEmpty(string? value) est annoté [NotNullWhen(false)] : après if (!string.IsNullOrEmpty(s)), s est considéré non null.
Pré- et postconditions : [AllowNull], [DisallowNull], [MaybeNull], [NotNull]
// Le getter ne renvoie jamais null, mais le setter accepte null (=> valeur par défaut)
[AllowNull]
public string DisplayName
{
get => _displayName;
set => _displayName = value ?? "Sans nom";
}
private string _displayName = "Sans nom";
// Méthode de garde : après l'appel, l'argument est not-null
public static void EnsureNotNull([NotNull] object? value, string paramName)
{
if (value is null) throw new ArgumentNullException(paramName);
}
[MaybeNull] signale à l’inverse qu’un retour ou un paramètre out peut être null même si son type ne l’indique pas ; depuis C# 9, la syntaxe T? sur un générique non contraint couvre la plupart de ces cas. En pratique, ArgumentNullException.ThrowIfNull(value) (annoté [NotNull]) remplace avantageusement les gardes maison.
Entre membres : [MemberNotNull] et [MemberNotNullWhen]
Quand l’initialisation est factorisée dans une méthode, le compilateur ne la « voit » pas depuis le constructeur. [MemberNotNull] le lui indique :
public class Invoice
{
private List<InvoiceLine> _lines;
public Invoice() => Reset(); // ✅ pas de CS8618
[MemberNotNull(nameof(_lines))]
public void Reset() => _lines = [];
public string? PaymentReference { get; private set; }
[MemberNotNullWhen(true, nameof(PaymentReference))]
public bool IsPaid => PaymentReference is not null;
}
if (invoice.IsPaid)
Console.WriteLine(invoice.PaymentReference.Length); // ✅
[NotNullIfNotNull]
Pour une méthode qui renvoie null seulement si son entrée est null :
[return: NotNullIfNotNull(nameof(input))]
public static string? Normalize(string? input) => input?.Trim().ToLowerInvariant();
string a = Normalize(" Paris "); // ✅ pas de warning : entrée non nulle
string? b = Normalize(null); // maybe-null
Enfin, [DoesNotReturn] et [DoesNotReturnIf(bool)] signalent qu’une méthode lève systématiquement (ou selon un booléen), ce qui permet au compilateur de poursuivre l’analyse correctement après l’appel.
5. required et init : garantir l’initialisation
Le warning CS8618 pousse souvent vers un constructeur, ou pire vers = null!. Depuis C# 11, le modificateur required oblige l’appelant à renseigner la propriété dans l’initialiseur d’objet :
public class CreateCustomerCommand
{
public required string Email { get; init; }
public required string LastName { get; init; }
public string? Phone { get; init; } // facultatif, explicitement nullable
}
var ok = new CreateCustomerCommand { Email = "a@b.fr", LastName = "Durand" };
// ❌ CS9035 : le membre requis 'LastName' doit être défini
var ko = new CreateCustomerCommand { Email = "a@b.fr" };
required et nullabilité sont orthogonaux : required string? Comment signifie « à renseigner explicitement, éventuellement avec null ». Combiné à init, on obtient des objets immuables et complets, dans la lignée des records. Un constructeur qui initialise lui-même tous les membres requis peut être marqué [SetsRequiredMembers].
6. Les génériques : T?, notnull, class?
Les génériques sont la partie la plus subtile, car T peut être un type valeur ou référence, nullable ou non.
// T? sans contrainte (C# 9+) : "la valeur par défaut de T"
// - pour T = string -> string? (peut être null)
// - pour T = int -> int (et non int?) !
public static T? FindOrDefault<T>(IEnumerable<T> items, Func<T, bool> predicate)
{
foreach (var item in items)
if (predicate(item)) return item;
return default;
}
// Contrainte notnull : T ne peut pas être un type nullable
public class Registry<TKey, TValue> where TKey : notnull
{
private readonly Dictionary<TKey, TValue> _items = new();
}
var r1 = new Registry<string, int>(); // ✅
var r2 = new Registry<string?, int>(); // ⚠ CS8714 : string? ne respecte pas notnull
| Contrainte | Types acceptés |
|---|---|
where T : class |
référence non nullable (string) |
where T : class? |
référence nullable ou non (string, string?) |
where T : struct |
type valeur non nullable |
where T : notnull |
référence ou valeur non nullable |
| aucune | tout, y compris string? et int? |
Le piège classique : T? sans contrainte, avec T = int, ne produit pas int?. Pour un vrai « absent » sur les types valeur, il faut where T : struct et T?, ou un type résultat dédié.
7. Migrer un projet existant
Activer <Nullable>enable</Nullable> sur une grosse solution produit souvent des centaines de warnings. Une migration progressive est plus réaliste.
Stratégie fichier par fichier
#nullable enable // active annotations + warnings jusqu'à la fin du fichier
#nullable disable // désactive tout
#nullable restore // revient au réglage du projet
#nullable enable warnings // seulement les warnings
#nullable enable annotations // seulement les annotations
Deux approches possibles :
- Bottom-up : laisser le projet en
disableet ajouter#nullable enableen tête des fichiers migrés, en commençant par les couches basses (domaine, utilitaires) qui sont consommées par les autres. - Top-down : passer le projet en
enable, puis ajouter#nullable disableen tête des fichiers non encore traités, et retirer progressivement ces directives.
La seconde approche a l’avantage que tout nouveau fichier naît avec les NRT activés. Les avertissements restants se suivent alors simplement par un grep '#nullable disable'.
Rendre les warnings bloquants
Une fois un projet migré, il faut empêcher la régression :
<PropertyGroup>
<Nullable>enable</Nullable>
<!-- tous les warnings de nullabilité deviennent des erreurs -->
<WarningsAsErrors>$(WarningsAsErrors);nullable</WarningsAsErrors>
</PropertyGroup>
Le mot-clé nullable regroupe l’ensemble des warnings de nullabilité, ce qui évite de lister CS8600, CS8602, etc. On peut aussi l’imposer à toute la solution via un Directory.Build.props, et le vérifier en CI (voir GitHub Actions pour un projet .NET).
graph LR
A["Projet legacy<br/>Nullable disable"] --> B["Nullable enable<br/>+ #nullable disable<br/>par fichier"]
B --> C["Migration<br/>fichier par fichier"]
C --> D["Plus aucun<br/>#nullable disable"]
D --> E["WarningsAsErrors<br/>nullable"]
style A fill:#F5A623
style C fill:#4A90E2
style E fill:#7ED321
8. Interaction avec EF Core
Avec les NRT activés, EF Core déduit le caractère obligatoire d’une colonne depuis l’annotation :
public class Customer
{
public int Id { get; set; }
public required string Name { get; set; } // NOT NULL
public string? Phone { get; set; } // NULL
// Navigation obligatoire : renseignée par EF au chargement
public Address Address { get; set; } = null!;
// Collection : initialisée, jamais null
public List<Order> Orders { get; set; } = [];
}
public class ShopContext(DbContextOptions<ShopContext> options) : DbContext(options)
{
// ✅ forme recommandée : pas de setter, pas de warning CS8618
public DbSet<Customer> Customers => Set<Customer>();
}
Points d’attention :
- Activer les NRT sur un modèle existant modifie le schéma : toutes les propriétés
stringdeviennentNOT NULL. Générer une migration juste après l’activation et la relire est indispensable. - La navigation obligatoire
Addressest l’un des rares cas où= null!est accepté (c’est d’ailleurs ce que recommande la documentation EF Core) : EF garantit la valeur lorsqu’elle est chargée, mais elle reste null si on oublie l’Include. Le!est ici un compromis, pas une garantie.
Pour le détail du mapping et de la configuration, voir la série Entity Framework.
9. Désérialisation JSON : RespectNullableAnnotations
Historiquement, System.Text.Json ignore les annotations : {"name": null} produit un Name null dans une propriété string, sans aucune erreur. Depuis .NET 9, l’option RespectNullableAnnotations change ce comportement :
using System.Text.Json;
#nullable enable
public record Product(string Name, string? Description);
var options = new JsonSerializerOptions { RespectNullableAnnotations = true };
// ❌ JsonException : le paramètre 'Name' n'accepte pas null
JsonSerializer.Deserialize<Product>("""{"Name":null}""", options);
// ⚠ Aucune exception : une propriété ABSENTE n'est pas vérifiée
var p = JsonSerializer.Deserialize<Product>("{}", options); // p.Name == null
Deux nuances importantes :
- Valeur
nullexplicite ≠ propriété absente. Pour exiger la présence, il fautrequired,[JsonRequired]ou, pour les paramètres de constructeur,RespectRequiredConstructorParameters = true(également .NET 9). - Limites : la vérification ne s’applique ni au type racine, ni aux éléments de collection (
List<string>etList<string?>sont indiscernables au runtime), ni aux membres génériques.
L’option peut être activée globalement par un feature switch :
<ItemGroup>
<RuntimeHostConfigurationOption
Include="System.Text.Json.Serialization.RespectNullableAnnotationsDefault"
Value="true" />
</ItemGroup>
En .NET 10, le preset JsonSerializerOptions.Strict active d’un coup RespectNullableAnnotations, RespectRequiredConstructorParameters, refuse les membres inconnus et les propriétés dupliquées. Dans une Minimal API ASP.NET Core, on applique l’option via builder.Services.ConfigureHttpJsonOptions(o => o.SerializerOptions.RespectNullableAnnotations = true);, ce qui transforme un payload invalide en erreur 400 plutôt qu’en NullReferenceException plus loin dans le code.
10. Bonnes pratiques
- ✅ Activer
<Nullable>enable</Nullable>sur tout nouveau projet, etWarningsAsErrorsdès que possible. - ✅ Préférer
is null/is not nullà== null: insensible à une surcharge d’opérateur (voir Égalité et identité). - ✅ Valider aux frontières (entrées d’API, désérialisation, base de données) puis manipuler des types non nullables dans le cœur métier.
- ✅ Utiliser
requiredplutôt que= null!pour les propriétés obligatoires. - ✅ Annoter les API publiques avec les attributs adaptés (
TryXxx, méthodes de garde). - ❌ Disperser des
!pour faire disparaître les warnings. - ❌ Considérer les NRT comme une garantie runtime : une bibliothèque non annotée ou de la réflexion peut toujours injecter un
null.
Résumé
| Élément | Rôle |
|---|---|
<Nullable>enable</Nullable> |
Active annotations et warnings pour le projet |
string? |
Référence pouvant valoir null (annotation compile-time uniquement) |
| Analyse de flux | Suit l’état maybe-null / not-null de chaque expression |
! |
Force l’état not-null sans vérification : à réserver aux cas justifiés |
[NotNullWhen], [MaybeNullWhen] |
Nullabilité conditionnée au retour booléen |
[AllowNull], [DisallowNull], [MaybeNull], [NotNull] |
Pré- et postconditions fines |
[MemberNotNull], [NotNullIfNotNull] |
Contrats entre membres, entrée/sortie |
required + init |
Initialisation obligatoire par l’appelant (C# 11) |
T?, notnull, class? |
Nullabilité dans les génériques |
#nullable, WarningsAsErrors nullable |
Migration progressive puis verrouillage |
| EF Core | Propriété non nullable = colonne NOT NULL |
RespectNullableAnnotations |
System.Text.Json refuse les null explicites (.NET 9+) |
Les nullable reference types ne suppriment pas null du langage, mais ils le rendent visible : chaque ? documente une intention, chaque warning signale un cas oublié. Bien utilisés, ils déplacent une grande partie des NullReferenceException de la production vers la compilation.