Nullable Reference Types sind eines meiner liebsten C#-Features. Der Compiler weiß, was null sein darf und was nicht, und meckert, bevor ich Mist baue. Bis zu dem Tag, an dem ich ein JSON deserialisiert habe, dem eine Pflicht-Property fehlte, und System.Text.Json mir seelenruhig ein Objekt zurückgab, dessen non-nullable String null war. Kein Fehler, keine Warnung, nichts. Dieser Post erklärt, warum das passiert, warum es völlig korrekt ist, und mit welchen drei Schaltern du es abstellst.
Der Tatort
Nichts Exotisches. Ein record mit Primärkonstruktor, beide Eigenschaften non-nullable, NRT projektweit an (<Nullable>enable</Nullable>):
public record Person(string Name, int Age);
Und ein JSON, dem name schlicht fehlt:
{ "age": 30 }
Jetzt deserialisieren, wie man es tausendmal tut:
var json = """{ "age": 30 }""";
var person = JsonSerializer.Deserialize<Person>(json)!;
Console.WriteLine(person.Name is null); // True
Console.WriteLine(person.Name.Length); // 💥 NullReferenceException
Der Compiler hat während des gesamten Builds keinen Mucks gemacht. Person.Name ist als string deklariert, also „darf" es nicht null sein, und trotzdem steht da zur Laufzeit eine NullReferenceException. Genau die Sorte Ausnahme, die NRT mir doch eigentlich austreiben sollte.
Warum das passiert (und warum es korrekt ist)
Der Knackpunkt, der hier viele (mich eingeschlossen) auf dem falschen Fuß erwischt:
Kernidee: Nullable Reference Types sind ein reines Compile-Time-Feature. Zur Laufzeit existieren sie nicht.
System.Text.Jsonsieht deine?/non-?-Annotationen schlicht nicht.
NRT landet als Metadaten und Compiler-Warnungen im Code, nicht als Laufzeit-Constraint. Die Annotation string (statt string?) ist eine Notiz für den Compiler, kein Vertrag, den die Runtime durchsetzt. Wenn JsonSerializer.Deserialize ein Objekt baut, passiert deshalb das hier:
- Für Property-Setter (
{ get; set; }): Fehlt der Wert im JSON, wird der Setter nie aufgerufen. Die Property behält ihren Default, alsonullbei Referenztypen. STJ „sieht" gar keine null-Zuweisung, es passiert einfach … nichts. - Für Konstruktor-Parameter (
record/Primärkonstruktor): Fehlt der Wert, hat STJ historischdefaulteingesetzt, alsonull, und den Konstruktor trotzdem aufgerufen.
Beides ist aus Sicht der Runtime völlig konsequent. Es gibt keine eingebaute Regel „diese Property muss im JSON vorhanden sein", nur weil ihr Typ non-nullable ist. Der Compiler kann das auch nicht prüfen, den JSON-Inhalt sieht er ja erst zur Laufzeit.
Das eigentlich Überraschende kommt aber erst: STJ war hier sogar mit Absicht lasch. Und zwar aus Rückwärtskompatibilität, dazu gleich mehr.
Die drei Schalter
Seit .NET 9 (und unverändert in .NET 10) gibt es drei Mechanismen, um genau diese Lücke zu schließen. Sie greifen an unterschiedlichen Stellen, und das ist der Teil, den man verstanden haben muss, sonst nimmt man den falschen.
1. required: fängt die fehlende Property
Das required-Keyword (C# 11) markiert eine Eigenschaft als verpflichtend. STJ respektiert das seit .NET 7: Fehlt der Wert im JSON, fliegt eine JsonException.
public class Person
{
public required string Name { get; set; }
public int Age { get; set; }
}
[JsonRequired] macht dasselbe, ohne dass das Keyword auch deine new-Aufrufe im Code zur Pflichtangabe zwingt, manchmal angenehmer.
2. RespectNullableAnnotations: fängt das explizite null
Diese Option (neu in .NET 9) bringt STJ dazu, deine NRT-Annotationen doch zur Laufzeit ernst zu nehmen:
var options = new JsonSerializerOptions
{
RespectNullableAnnotations = true
};
Damit wirft { "name": null, "age": 30 } eine Exception, weil Name non-nullable ist. Aber Achtung: Diese Option prüft, ob null zugewiesen wird, nicht, ob die Property fehlt. Bei einem fehlenden Property-Setter wird ja nie etwas zugewiesen, also greift sie hier nicht.
3. RespectRequiredConstructorParameters: fängt den Konstruktor-Pfad
Das war in meinem record-Fall der entscheidende Schalter. Ein Konstruktor-Parameter ohne Default-Wert ist im C#-Sinne eigentlich verpflichtend, beim normalen new musst du ihn angeben. STJ hat das nur ignoriert. Diese Option (ebenfalls .NET 9) dreht das um:
var options = new JsonSerializerOptions
{
RespectRequiredConstructorParameters = true
};
Bei record Person(string Name, int Age) und fehlendem name gibt es jetzt sauber eine JsonException, statt still ein Objekt mit Name == null.
Die Wahrheitstabelle
Welcher Schalter greift wann? Genau hier liegt der Hund begraben, die Zeilen sind nicht austauschbar:
| Szenario | required / [JsonRequired] |
RespectNullableAnnotations |
RespectRequiredConstructorParameters |
|---|---|---|---|
JSON lässt name weg, Property-Setter |
✅ wirft | ⚠️ greift nicht* | n/a |
JSON lässt name weg, Konstruktor-Param |
✅ wirft | ⚠️ greift nicht* | ✅ wirft |
JSON hat "name": null (egal welcher Stil) |
⚠️ akzeptiert (Property war ja da) | ✅ wirft | n/a |
Default-Wert am Parameter (string Name = "x") |
n/a | n/a | bewusst optional, greift nicht |
* „greift nicht" heißt: Setter/Zuweisung wird nie aufgerufen, der Wert bleibt still auf null.
Die zwei Erkenntnisse daraus:
- „Fehlt" und „ist explizit null" sind zwei verschiedene Fälle.
RespectNullableAnnotationsdeckt nur den zweiten ab. Für den ersten brauchst durequiredbzw.RespectRequiredConstructorParameters. - Property-Setter und Konstruktor sind zwei verschiedene Pfade.
RespectRequiredConstructorParametersist kein Allheilmittel, es greift ausschließlich am Konstruktor.
Das vollständige Repro zum Mitnehmen
Eine Program.cs, .NET 10, die alle Fälle nacheinander durchspielt, gut zum Festhalten fürs Team:
using System.Text.Json;
// --- Variante A: record mit Primärkonstruktor ---
record PersonCtor(string Name, int Age);
// --- Variante B: klassische Properties mit Settern ---
class PersonProps
{
public required string Name { get; set; }
public int Age { get; set; }
}
static void Try(string label, Action action)
{
try { action(); Console.WriteLine($"[{label}] kein Fehler"); }
catch (JsonException e) { Console.WriteLine($"[{label}] JsonException: {e.Message}"); }
catch (Exception e) { Console.WriteLine($"[{label}] {e.GetType().Name}: {e.Message}"); }
}
var missing = """{ "age": 30 }"""; // name fehlt komplett
var explicitNull = """{ "name": null, "age": 30 }""";
var lax = new JsonSerializerOptions(); // alles aus = Default
var strict = new JsonSerializerOptions
{
RespectNullableAnnotations = true,
RespectRequiredConstructorParameters = true
};
// 1. Default-Verhalten: die Falle
Try("ctor / missing / lax", () =>
{
var p = JsonSerializer.Deserialize<PersonCtor>(missing, lax)!;
_ = p.Name.Length; // 💥 NullReferenceException
});
// 2. Konstruktor-Pfad mit RespectRequiredConstructorParameters
Try("ctor / missing / strict", () =>
JsonSerializer.Deserialize<PersonCtor>(missing, strict));
// 3. Property-Pfad mit required
Try("props / missing / required", () =>
JsonSerializer.Deserialize<PersonProps>(missing, lax));
// 4. Explizites null mit RespectNullableAnnotations
Try("props / explicit null / strict", () =>
JsonSerializer.Deserialize<PersonProps>(explicitNull, strict));
Erwartete Ausgabe:
[ctor / missing / lax] NullReferenceException: ...
[ctor / missing / strict] JsonException: ... 'Name' ...
[props / missing / required] JsonException: ... required ... 'Name' ...
[props / explicit null / strict] JsonException: ... 'Name' ...
Die erste Zeile ist das Problem. Die anderen drei sind die Lösung, je nach dem, wie dein Objekt gebaut wird.
Projektweit erzwingen statt an jeder Aufrufstelle
Was mich am meisten genervt hätte: diese Optionen an jede JsonSerializerOptions-Instanz im Code zu hängen. Muss man nicht. .NET 9 hat Feature-Switches eingeführt, die den Default für jede Options-Instanz im Projekt setzen, gepflegt in der .csproj (oder zentral in einer Directory.Build.props):
<ItemGroup>
<RuntimeHostConfigurationOption
Include="System.Text.Json.Serialization.RespectNullableAnnotationsDefault"
Value="true" Trim="false" />
<RuntimeHostConfigurationOption
Include="System.Text.Json.Serialization.RespectRequiredConstructorParametersDefault"
Value="true" Trim="false" />
</ItemGroup>
Und die zweite Hälfte der Härtung passiert beim Definieren der Typen. Dafür eskaliere ich die passende Compiler-Warnung in der .editorconfig zum Fehler:
# .editorconfig
[*.cs]
# Non-nullable property muss beim Verlassen des Konstruktors einen Wert haben
# -> zwingt zu 'required' oder Initialisierung
dotnet_diagnostic.CS8618.severity = error
Wichtig zur Einordnung: Es gibt keine .editorconfig-Regel, die das STJ-Laufzeitverhalten erzwingt, das ist Compile-Time-Werkzeug und sieht den JSON-Inhalt nie. Die editorconfig härtet die Typdefinition (CS8618 → required), die csproj-Feature-Switches härten die Laufzeit. Erst beides zusammen schließt die Lücke wirklich.
Und warum war das überhaupt so lasch?
Der Twist, der dem Ganzen Sinn gibt: Alle drei Optionen sind opt-in, und RespectRequiredConstructorParameters ist es per Default sogar auf einem System mit NRT-an. Das ist kein Versehen, sondern bewusste Rückwärtskompatibilität. STJ gibt es seit .NET Core 3.0; non-nullable-Parameter wurden jahrelang als „darf fehlen, dann eben default" behandelt. Würde Microsoft das per Default umdrehen, bräche es schlagartig unzählige bestehende Deserialisierungen, die sich klammheimlich auf dieses lasche Verhalten verlassen. Also: neue, korrektere Semantik, aber nur, wenn du sie aktiv einschaltest.
Fazit
- NRT ist Compile-Time-Deko, STJ ist Laufzeit. An dieser Grenze fällt deine Sicherheitsannahme leise auseinander: der Compiler ist grün, das Feld trotzdem null.
- Drei Schalter, drei Stellen:
required/[JsonRequired]fängt fehlende Werte,RespectNullableAnnotationsfängt explizitesnull,RespectRequiredConstructorParametersmacht dasselbe für denrecord/Konstruktor-Pfad. - „Fehlt" ≠ „ist null" und Setter ≠ Konstruktor: die Wahrheitstabelle zeigt, warum man leicht den falschen Schalter erwischt.
- Projektweit härten: Feature-Switches in
Directory.Build.propsfür die Laufzeit,CS8618 = errorin der editorconfig für die Typdefinition. - Per Default lasch aus gutem Grund: Rückwärtskompatibilität. Die korrektere Semantik ist da, du musst sie nur einschalten.
Seitdem stehen diese drei Zeilen in meiner Directory.Build.props, und ein fehlendes Pflichtfeld im JSON fliegt mir nicht mehr erst drei Schichten später als NullReferenceException um die Ohren, sondern sofort beim Deserialisieren als das, was es ist: ungültige Eingabe.
Vielen Dank an Florian für den Hinweis, der diesen Artikel ausgelöst hat!