Generics Constraints in C#: Writing Precise Generic Code

Generics let you write code that works with any type. Constraints let you write code that works with any type that meets specific requirements. Without constraints, a generic type parameter is essentially object — you can do almost nothing with it. Constraints unlock members, operators, and constructors that make generic code genuinely useful.

The Basic Constraints

C# provides several constraint categories, applied with the where clause:

Example.cs
// Must be a reference type
public class Cache<T> where T : class { }

// Must be a non-nullable value type
public struct Wrapper<T> where T : struct { }

// Must have a parameterless constructor
public T CreateInstance<T>() where T : new() => new T();

// Must not be null (reference or value type, but not nullable)
public void Process<T>(T value) where T : notnull { }

These constraints control the fundamental nature of the type parameter.

Interface and Base Class Constraints

The most common constraints require a type to implement an interface or derive from a base class:

Example.cs
public class Repository<T> where T : IEntity
{
    public void Save(T entity)
    {
        // We can access IEntity members because of the constraint
        Console.WriteLine($"Saving entity with ID: {entity.Id}");
    }
}

public interface IEntity
{
    int Id { get; }
}

You can combine multiple interface constraints:

Example.cs
public class SortedFilteredCollection<T>
    where T : IComparable<T>, IEquatable<T>
{
    private readonly List<T> _items = [];

    public void Add(T item) { /* ... */ }

    public T? Find(T target) =>
        _items.FirstOrDefault(x => x.Equals(target));
}

The unmanaged Constraint

The unmanaged constraint restricts a type to unmanaged types — value types that contain no references:

Example.cs
public unsafe void WriteToBuffer<T>(T value, byte* destination) where T : unmanaged
{
    int size = sizeof(T); // Only works with unmanaged constraint
    Buffer.MemoryCopy(&value, destination, size, size);
}

This is essential for interop scenarios and high-performance memory manipulation.

Combining Constraints

Constraints can be combined, but some combinations are mutually exclusive:

Example.cs
public class EventStore<TEvent>
    where TEvent : class, IEvent, new()
{
    public TEvent CreateDefault()
    {
        var evt = new TEvent();  // Requires new()
        evt.Timestamp = DateTime.UtcNow;  // Requires IEvent
        return evt;
    }
}

You cannot combine struct with class, or struct with new() (structs always have a parameterless constructor).

Generic Constraints on Multiple Parameters

When a method or class has multiple type parameters, each can have independent constraints:

Example.cs
public class Mapper<TSource, TDestination>
    where TSource : class
    where TDestination : class, new()
{
    private readonly Func<TSource, TDestination> _mapFunc;

    public Mapper(Func<TSource, TDestination> mapFunc)
    {
        _mapFunc = mapFunc;
    }

    public TDestination Map(TSource source) => _mapFunc(source);
}

Type Relationships

You can constrain one type parameter to be related to another:

Example.cs
public class ComparableCollection<T> where T : IComparable<T>
{
    private readonly List<T> _items = [];

    public T Max() => _items.Max()!;
}

// Constrain TDerived to be a subtype of TBase
public TDerived Convert<TBase, TDerived>(TBase item)
    where TDerived : TBase
{
    return (TDerived)item!;
}

Static Abstract Interface Members (C# 11+)

With static abstract members in interfaces, constraints unlock operator usage and factory patterns:

Example.cs
public T Add<T>(T left, T right) where T : INumber<T>
{
    return left + right; // Works because INumber<T> defines operator +
}

public T Parse<T>(string input) where T : IParsable<T>
{
    return T.Parse(input, null); // Static method called on the type parameter
}

This is a significant expansion of what generic constraints can express, enabling truly generic mathematical code.

Real-World Example: A Generic Validator

Example.cs
public interface IValidatable
{
    IEnumerable<string> Validate();
}

public class ValidationPipeline<T> where T : IValidatable
{
    private readonly List<Func<T, IEnumerable<string>>> _rules = [];

    public ValidationPipeline<T> AddRule(Func<T, IEnumerable<string>> rule)
    {
        _rules.Add(rule);
        return this;
    }

    public ValidationResult Validate(T entity)
    {
        var errors = entity.Validate()
            .Concat(_rules.SelectMany(rule => rule(entity)))
            .ToList();

        return errors.Count == 0
            ? ValidationResult.Success()
            : ValidationResult.Failure(errors);
    }
}

Choosing the Right Constraints

Use constraints liberally — they make your generic code safer and more useful:

Every constraint you add is a contract that callers must satisfy and that your implementation can rely on. They turn generic code from a loose template into a precise specification.