Back to Blog
C#

C# Non-Nullable Reference Type Syntax

Learn the C# non-nullable reference type syntax: enable the nullable context, use the ? and ! operators, and handle compiler warnings in new and legacy code.

nullable reference typesC# syntaxnull safetycompiler warnings
Diagram showing nullable and non-nullable reference type annotations in C# code

When you enable nullable reference types in C#, the compiler treats reference types you declare as non-nullable by default. That means a plain string declaration promises the value is not null, while string? explicitly allows null. Understanding the C# non-nullable reference type syntax and how the compiler enforces it is essential for writing null-safe code.

Enabling the Nullable Context

The nullable context is not on by default in projects created before C# 8. You can enable it at the project level with the <Nullable>enable</Nullable> element in the .csproj file, or per file with the #nullable enable directive. The project-level setting is usually preferable because it applies consistently across the entire codebase.

<Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> <OutputType>Exe</OutputType> <TargetFramework>net8.0</TargetFramework> <Nullable>enable</Nullable> </PropertyGroup> </Project>

When enabled, the compiler emits warnings for code that may dereference a null value, and it uses the syntax you write to track null state across assignments and method calls.

Declaring Nullable and Non-Nullable Reference Types

The core syntax is the ? suffix on a reference type. A declaration without ? is non-nullable; with ? it is nullable. This distinction is purely a compile-time annotation. It does not change the runtime type or add checks.

string nonNullable = "hello"; // compiler assumes not null string? nullable = null; // explicitly allows null

The compiler uses these annotations to analyze flow. For example, if you try to pass nullable to a method that expects a non-nullable string, you get a warning unless you check for null first.

void PrintLength(string text) { Console.WriteLine(text.Length); } string? maybe = GetValue(); if (maybe is not null) { PrintLength(maybe); // allowed after null check }

This syntax also works on array types and delegate types. Generic type parameters have additional rules, covered below.

The Null-Forgiving Operator

The null-forgiving operator ! suppresses the compiler's null-state analysis for an expression. It tells the compiler "I know this value is not null, even though you can't prove it." It does not perform a runtime check and does not throw an exception if the value is actually null.

string? name = GetName(); int length = name!.Length; // no warning, but runtime risk if name is null

Use ! sparingly. It is most useful when you have external information the compiler cannot infer, such as a value set by a framework or a field initialized in a method that the compiler cannot see. Overusing it defeats the purpose of nullable analysis.

Handling Warnings in Constructors and Properties

A common warning appears when a non-nullable property is not initialized in the constructor. The compiler cannot guarantee that the property has a value before it is read. You have several options: assign a default value, use a nullable type, or use the required modifier (C# 11+).

public class Person { public string Name { get; set; } = string.Empty; // default public string? Nickname { get; set; } // nullable }

For fields that are set later through a method, you can use the null-forgiving operator at the point of declaration to suppress the uninitialized-field warning. This should be done only when you are certain the field is always set before it is read.

public class Service { private string _connectionString = null!; public void Initialize(string connectionString) { _connectionString = connectionString; } public void Connect() { // If Initialize is guaranteed to be called first, `_connectionString` // is non-null and the warning is safely suppressed. Console.WriteLine(_connectionString.Length); } }

A cleaner approach is to make the field nullable and check it before use, or restructure so initialization happens in the constructor.

Nullable Annotations and Generic Types

Generic types require special attention. When T is constrained to a reference type, T? means a nullable reference:

public T? GetValue<T>(T? input) where T : class { return input; }

For unconstrained type parameters, C# 9 and later allow T? as a null-state annotation. It does not turn a value type argument into a Nullable<T>; for value type arguments the annotation is effectively T. The [MaybeNull] attribute from System.Diagnostics.CodeAnalysis is useful to express the same intent in signatures where you cannot use ?, or when you want the annotation to be explicit.

using System.Diagnostics.CodeAnalysis; [return: MaybeNull] public T GetValue<T>() { return default; }

This is an advanced area; the compiler's null-state analysis for generics is conservative. Understand the constraints and attributes available before designing generic APIs.

Interoperability with Legacy Code

When you enable the nullable context on a large existing codebase, you will see many warnings. The compiler treats all reference types in legacy code as non-nullable, which may not match reality. To adopt the feature incrementally, you can use #nullable disable in specific files or regions, or use the ? and ! annotations to document your actual intent.

#nullable disable // Legacy code that does not use nullable annotations #nullable restore

Another strategy is to enable the context at the project level but avoid turning nullable warnings into errors, so you can fix them over time without breaking the build. The goal is to gradually increase the coverage until the entire codebase is annotated correctly.

Runtime Behavior and Compile-Time Guarantees

The nullable reference type feature has no runtime effect. The annotations are compile-time metadata: the compiler uses them for null-state analysis and, for API signatures, emits them into assembly metadata so other compilers and analyzers can understand the intended null state. The runtime does not enforce or use them, and there are no runtime checks or performance overhead from the annotations. The guarantees are compile-time only.

This means you cannot rely on the compiler to prevent null reference exceptions at runtime. A value declared as non-nullable can still be null if it comes from an external library, reflection, or a null! expression. The feature is a tool for expressing intent and catching mistakes early, not a runtime safety net.

Because of this, you should combine nullable annotations with defensive runtime checks when interacting with unannotated code or external systems. For example, when deserializing JSON, a property marked non-nullable may still be missing, so you need to validate the data.

Adopting Nullable Reference Types Incrementally

Introducing nullable reference types to an existing project is a gradual process. Start by enabling the context and fixing warnings in the most critical paths, such as public APIs and data models. Use the #nullable directives to isolate legacy code that is not ready. As you add new code, write it with the nullable syntax from the beginning.

A practical approach is to enable the context in a new project and keep it enabled for all new files. For existing files, enable it file by file and address the warnings. Over time, the compiler's analysis becomes more useful because the annotations are accurate.

One common pitfall is using ! to silence warnings without understanding why the warning exists. Each use of ! should be justified. If a value can actually be null, you should use a nullable type and handle the null case explicitly. The ! operator is for cases where you have external knowledge, not for avoiding proper null handling.

Another consideration is the interaction with Nullable attributes like [NotNull] and [MaybeNull]. These attributes give the compiler additional information about the null state of parameters and return values. They are especially useful when you cannot change the signature, such as when implementing an interface or overriding a method from a library that does not use nullable annotations.

By understanding the syntax and the compiler's behavior, you can use nullable reference types to make your code more self-documenting and reduce null-related bugs without adding runtime overhead.