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, T is non-nullable, and T? 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 be null, even if T is 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 an out parameter is not null if the method returns true:
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.