Constrain Generics to Reference or Value Types in C

In C#, generic type parameters can be restricted to specific categories of data using generic constraints declared with the where keyword. Constraining a generic parameter to either a reference type or a value type ensures compile-time type safety, dictates whether the type can be null, and grants access to specific operations associated with that category. This article explains how to apply the class constraint for reference types and the struct constraint for value types, along with their practical usage and rules.

Constraining to Reference Types (where T : class)

To restrict a generic type parameter to reference types only, append the where T : class constraint to the class, interface, or method signature. This ensures that the type argument must be a reference type, such as a class, interface, delegate, record, or array.

public class ReferenceContainer<T> where T : class
{
    public T Value { get; set; }

    public void CheckNull()
    {
        if (Value is null)
        {
            Console.WriteLine("Value is null.");
        }
    }
}

Rules and Behaviors for class:

  • Valid Types: Any custom class, string, interface, delegate, or array.
  • Invalid Types: Primitive types (int, bool, double), custom struct definitions, and enumerations (enum).
  • Nullability: If nullable reference types are enabled, where T : class requires a non-nullable reference type in C# 8.0 and later. Use where T : class? if the type argument is allowed to be nullable.

Constraining to Value Types (where T : struct)

To restrict a generic type parameter to value types, use the where T : struct constraint. This limits the type parameter to non-nullable value types.

public class ValueContainer<T> where T : struct
{
    public T Value { get; set; }

    public void Reset()
    {
        // Value types always have a default parameterless value
        Value = default; 
    }
}

Rules and Behaviors for struct:

  • Valid Types: Numeric primitives (int, float, decimal), bool, char, enum, and custom struct types.
  • Invalid Types: Classes, strings, interfaces, delegates, and Nullable<T> types (e.g., int?).
  • Default Constructor: The struct constraint implicitly satisfies the new() constraint, meaning you can instantiate T using new T().

Specialized Value Type Constraints

C# provides additional, more specific value-type constraints:

  • unmanaged Constraint: where T : unmanaged restricts T to types that are value types and contain no reference type fields at any level of nesting. This is useful for low-level interop and pointer manipulation.
  • notnull Constraint: where T : notnull restricts T to non-nullable types, encompassing both non-nullable value types and non-nullable reference types.

Combining Constraints

When combining multiple constraints on a single type parameter, the type constraint (class or struct) must always appear first in the constraint list:

// Valid: class constraint comes first
public void ProcessReference<T>(T item) where T : class, IComparable<T>, new()
{
}

// Valid: struct constraint comes first
public void ProcessValue<T>(T item) where T : struct, IComparable<T>
{
}

Note that class and struct are mutually exclusive and cannot be applied to the same type parameter.