Nullable Annotations on C# Generic Types
Handling nullable annotations on generic type parameters in C#
requires balancing the differences between nullable value types and
nullable reference types. Because an unconstrained generic parameter
T can represent either a reference type or a value type,
the compiler requires specific constraints or nullability attributes to
correctly determine how T? behaves. This guide explains how
to properly annotate and constrain generic type parameters using modern
C# features.
Unconstrained Generic
Type Parameters (T?)
Starting in C# 9.0, you can annotate an unconstrained generic type
parameter as T?. When T is unconstrained,
T? represents an unknown type that can be null:
public class Container<T>
{
public T? Value { get; set; }
public Container(T? value)
{
Value = value;
}
}If T is a reference type (like string),
T? is treated as a nullable reference type
(string?). If T is a non-nullable value type
(like int), T? is treated as
Nullable<int> (int?). If T
is instantiated as an already-nullable value type (like
int?), T? remains int?.
The notnull Constraint
To prevent callers from supplying a nullable reference type or a
nullable value type, apply the notnull constraint:
public class Dictionary<TKey, TValue> where TKey : notnull
{
public void Add(TKey key, TValue value) { }
}This prevents usages like Dictionary<string?, int>
or Dictionary<int?, int>, ensuring TKey
is guaranteed to be non-null at compile time.
Reference Type
Constraints: class vs. class?
When a generic parameter must be a reference type, you have two primary options:
where T : class: Enforces that the type argument must be a non-nullable reference type. Inside the generic class or method,Tis non-nullable, andT?represents a nullable reference type.
public void ProcessReference<T>(T item, T? fallback) where T : class
{
// item cannot be null; fallback can be null
}where T : class?: Allows the type argument to be either a nullable or non-nullable reference type. This is useful when you want to enforce reference types while giving the caller flexibility.
public class ReferenceContainer<T> where T : class?
{
private T _item;
public ReferenceContainer(T item) => _item = item;
}Value Type Constraints:
struct
To ensure a type parameter is strictly a value type, use
where T : struct:
public void ProcessValue<T>(T item, T? optionalItem) where T : struct
{
// item is T
// optionalItem is System.Nullable<T>
}This enforces that T cannot be a reference type, and
T? explicitly creates a Nullable<T>.
Code Analysis Attributes for Generic Methods
Sometimes type constraints are too restrictive, especially when
returning default(T) from methods that accept an
unconstrained generic T. In these cases, use attributes
from the System.Diagnostics.CodeAnalysis namespace:
[return: MaybeNull]: Indicates that the return value may benull, even ifTis technically non-nullable:
using System.Diagnostics.CodeAnalysis;
public T? Find<T>(IEnumerable<T> source, Func<T, bool> predicate)
{
foreach (var item in source)
{
if (predicate(item)) return item;
}
return default; // Compiler allows this even if T is a non-nullable type
}[NotNullWhen]: Informs the compiler that anoutparameter is not null if the method returnstrue:
public bool TryGetValue<T>(string key, [NotNullWhen(true)] out T? value)
{
// If this returns true, callers can safely use 'value' without null checks.
value = default;
return false;
}Applying the correct constraints (class,
class?, struct, or notnull)
alongside conditional nullability attributes provides the compiler with
the necessary context to enforce null safety across generic APIs.