Back to Blog
C#

Using C# Nullable Reference Types in Real Code

c# nullable reference type usage: Learn how to use C# nullable reference types: enabling the context, annotating with ?, handling warnings, and applying null-analysis...

nullable reference typesnull safetyC# compiler warningscode analysis.NET
Editorial illustration showing a C# code editor panel with a question mark symbol inside a shield, representing compile-time null safety.

c# nullable reference type usage requires a clear understanding of the core syntax, runtime behavior, and practical implementation patterns demonstrated in the examples below.

Nullable reference types shift null handling from runtime crashes to compile-time warnings. When you enable the feature, the compiler tracks which reference-typed variables are allowed to be null and which are expected to hold a value. The goal is to catch likely NullReferenceException paths before the code runs, not to change how null behaves at runtime.

What Nullable Reference Types Change at Compile Time

Nullable reference types add a nullable annotation context and a null warning context to the compiler. When both are enabled, the compiler treats a plain string as a non-nullable reference type and string? as a nullable reference type. The distinction exists only in the compiler's analysis; at runtime, both are still System.String references that can hold null.

The practical effect is that the compiler emits warnings when you assign a potentially null value to a non-nullable variable, when you dereference a nullable value without checking it, or when you pass a nullable value to a parameter that does not accept null.

Enabling the Nullable Context

The feature is not on by default for projects that target older language versions. You enable it per project in the .csproj file:

<PropertyGroup> <Nullable>enable</Nullable> </PropertyGroup>

This sets both the annotation context and the warning context to enabled for all source files in the project. You can also use #nullable enable and #nullable disable directives inside individual files when you need finer control, for example when migrating a large file incrementally.

The <Nullable>enable</Nullable> setting applies to the whole project, which is usually what you want for new code. For existing codebases, enabling it project-wide will surface a large number of warnings at once, so many teams enable the annotation context first and treat warnings as a backlog rather than a build gate.

Annotating Reference Types with ?

A ? suffix marks a reference type as nullable:

#nullable enable public class Customer { public string Name { get; set; } = ""; public Address? ShippingAddress { get; set; } }

Here Name is declared as non-nullable, and the compiler will warn if you assign null to it. ShippingAddress is nullable, so the compiler expects callers to check it before dereferencing.

The annotation propagates through method signatures:

public string GetCity(Customer customer) { return customer.ShippingAddress?.City ?? "Unknown"; }

The ?. operator short-circuits when ShippingAddress is null, and ?? "Unknown" supplies a fallback so the method returns a non-nullable string. This is the typical pattern: the compiler does not remove null checks, it tells you where they are missing.

The Null-Forgiving Operator !

Sometimes you know a value is not null even though the compiler cannot prove it. The null-forgiving operator ! suppresses the warning:

string name = customer.Name!;

Using ! does not change the runtime value. If customer.Name is actually null, the code will still throw when dereferenced later. The operator only tells the compiler to stop warning at that point. It is most useful at boundaries where external code, serialization, or a framework guarantees a value that the compiler cannot see.

Overusing ! weakens the analysis. Every suppression is a place where a null value can slip through without a warning, so the operator should be reserved for cases where the guarantee is real and documented.

How Warnings Guide Where Null Checks Belong

The compiler's warnings are the main feedback loop. A typical warning appears when you dereference a nullable value:

public string GetCity(Customer customer) { return customer.ShippingAddress.City; // CS8602: Dereference of a possibly null reference }

The fix is to check before dereferencing:

public string GetCity(Customer customer) { if (customer.ShippingAddress is null) { return "Unknown"; } return customer.ShippingAddress.City; }

After the null check, the compiler narrows the type of ShippingAddress to non-nullable within the remaining scope. This flow analysis is the core of the feature: it models control flow and tracks whether a nullable value has been checked.

Runtime Behavior: Compile-Time Metadata Only

Nullable reference type annotations are stored as metadata in the assembly, but they do not alter execution. There is no runtime null check inserted by the compiler, no performance cost for the annotation itself, and no change to how the garbage collector treats the reference. The feature is entirely a compile-time and design-time aid.

This has an important consequence: a method declared with a non-nullable parameter can still receive null at runtime if the caller was compiled without nullable context, or if the caller uses reflection or a dynamic binding. The annotation is a contract for the compiler, not a runtime guard. If you need a hard runtime guarantee, you still need explicit argument validation.

Attributes That Improve Null Analysis

Several attributes give the compiler more information about null flow, especially around method calls and generics.

NotNullWhen describes an output parameter or return value that is non-null when a condition is true:

public static bool TryGetName(Customer customer, [NotNullWhen(true)] out string? name) { name = customer.Name; return name is not null; }

The compiler then knows that inside an if (TryGetName(customer, out var name)) block, name is non-null.

NotNullIfNotNull states that a return value is non-null whenever a specified argument is non-null:

[return: NotNullIfNotNull(nameof(value))] public static string? Normalize(string? value) { return value?.Trim(); }

This is common in extension methods and helper libraries where the output nullability mirrors the input.

MaybeNull and NotNull are useful in generic code where the compiler cannot infer nullability from the type parameter. For example, a method that returns the default value of a type parameter can be annotated with [MaybeNull] to indicate that the result may be null even when the type parameter is non-nullable.

These attributes are part of the System.Diagnostics.CodeAnalysis namespace and are compiled into the assembly metadata, so they also inform callers in other projects.

Where Analysis Falls Short

The flow analysis is conservative by design. It does not track nullability across method calls unless attributes describe the contract. It does not understand that a field is always initialized in a constructor if the assignment happens through a helper method. It also does not analyze values stored in collections, so a List<string?> element is treated as nullable even if you know every element was added as non-null.

Array and collection access is a common source of false warnings:

var names = new string?[] { "a", "b" }; string first = names[0]; // CS8600: Converting null literal or possible null value

The compiler cannot verify that index 0 is non-null. You either check the element, use !, or restructure the data so nullability is explicit at the type level.

Another gap is fields initialized through a helper:

public class Order { private string _id; public Order() { Initialize(); } private void Initialize() { _id = "generated"; } }

The compiler does not track that Initialize assigns _id, so it warns that _id is uninitialized. The common fix is to initialize the field inline or in the constructor directly, which also makes the initialization order easier to reason about.

Maintaining a Nullable-Aware Codebase

Once nullable is enabled, treat warnings as part of the public API surface. Changing a parameter from string to string? is a semantic change: callers that previously passed a value now know null is allowed, and callers that relied on the non-null guarantee need to handle the new possibility. This matters in libraries where consumers may be compiled with a different nullable context.

A practical approach is to keep the nullable context enabled in all projects and to configure warnings as errors in CI for new code, while allowing a baseline for legacy files. The compiler also emits CS8632 when a ? annotation appears in a context where nullable is disabled, which helps catch accidental annotations in files that have not been migrated.

The metadata annotations are visible to other languages and tools that read .NET assemblies, so they also improve editor tooling, refactoring, and API documentation. The cost is mainly in the initial migration effort and the discipline required to avoid overusing !.

c# nullable reference type usage: Practical Usage and Code E | RYUSLOG DEV