C# Generic Constraints

C# generic constraints let you tell the compiler what kinds of types may be used for a generic type parameter. They matter because unconstrained T is almost unknown: you can store it, pass it around, compare it to null only in limited cases, and call members from object, but you cannot safely call custom methods. A constraint turns a generic placeholder into a promise, such as “T must implement IComparable<T>” or “T must have a public parameterless constructor.”

Overview: How Generic Constraints Work

A generic declaration such as static T Max<T>(T left, T right) works with many possible types. That flexibility also means the compiler must type-check the method body before it knows whether callers will use int, string, or a custom class. Without a constraint, the compiler cannot assume that T has a CompareTo method, a default constructor, an Id property, or any operator such as >.

A constraint is written in a where clause after the generic parameter list. It narrows the valid type arguments and unlocks operations inside the generic code. For example, where T : IComparable<T> means every valid T must implement that interface, so the generic method can call left.CompareTo(right). If a caller tries to use a type that does not satisfy the constraint, the compiler rejects the call.

Constraints are part of the generic member’s metadata. The compiler uses them during type checking, and the CLR also understands the constraints when loading and verifying generic code. Constraints do not create a different source copy for each type. They are compile-time and runtime rules attached to the generic definition. This is different from dynamically checking a type with is or reflection after the program starts.

The most common constraints are class for reference types, struct for non-nullable value types, notnull for non-nullable type arguments in nullable-aware code, a base class constraint, an interface constraint, and new() for public parameterless construction. C# also supports specialized constraints such as unmanaged and Enum for lower-level or enum-focused APIs.

Syntax

class Repository<TEntity> where TEntity : Entity, new()
{
    public TEntity Create()
    {
        return new TEntity();
    }
}

static T Max<T>(T left, T right) where T : IComparable<T>
{
    return left.CompareTo(right) >= 0 ? left : right;
}
Constraint Meaning
where T : class T must be a reference type.
where T : struct T must be a non-nullable value type.
where T : notnull T should be non-nullable in nullable-aware code.
where T : BaseType T must inherit from the specified base class.
where T : ISomeInterface T must implement the specified interface.
where T : new() T must have a public parameterless constructor. It must be listed last.
where T : unmanaged T must be a non-nullable value type containing no reference-type fields.

You can apply constraints to multiple type parameters with separate where clauses. When combining constraints, put at most one primary kind such as class, struct, notnull, or a base class first, then interfaces, then new() last.

Examples

Using an Interface Constraint to Compare Values

using System;

class Program
{
    static void Main()
    {
        Console.WriteLine(Larger(10, 25));
        Console.WriteLine(Larger("pear", "apple"));
    }

    static T Larger<T>(T left, T right) where T : IComparable<T>
    {
        return left.CompareTo(right) >= 0 ? left : right;
    }
}

Output:

25
pear

The method can call CompareTo because the constraint guarantees that every T implements IComparable<T>. Both int and string satisfy the constraint, so the same method works for numbers and text while still being type-safe.

Using a Base Class Constraint

using System;
using System.Collections.Generic;

class Program
{
    static void Main()
    {
        List<Customer> customers = new List<Customer>
        {
            new Customer { Id = 101, Name = "Mina" },
            new Customer { Id = 102, Name = "Omar" }
        };

        PrintIds(customers);
    }

    static void PrintIds<T>(IEnumerable<T> items) where T : Entity
    {
        foreach (T item in items)
        {
            Console.WriteLine($"{item.Id}: {item.GetLabel()}");
        }
    }
}

class Entity
{
    public int Id { get; init; }

    public virtual string GetLabel()
    {
        return $"Entity {Id}";
    }
}

class Customer : Entity
{
    public string Name { get; init; } = "";

    public override string GetLabel()
    {
        return Name;
    }
}

Output:

101: Mina
102: Omar

The where T : Entity constraint lets PrintIds use Id and GetLabel. The method still accepts any future type derived from Entity, not only Customer.

Using new() to Create Generic Objects

using System;

class Program
{
    static void Main()
    {
        AuditEntry entry = Factory.Create<AuditEntry>();
        entry.Message = "User signed in";
        Console.WriteLine(entry.Message);
    }
}

static class Factory
{
    public static T Create<T>() where T : new()
    {
        return new T();
    }
}

class AuditEntry
{
    public string Message { get; set; } = "Created";
}

Output:

User signed in

The new() constraint is required because generic code cannot call new T() unless it knows T has a public parameterless constructor. This constraint is useful for simple factories, but it only supports parameterless construction, so it is not a replacement for dependency injection or rich constructors.

Combining Constraints

using System;

class Program
{
    static void Main()
    {
        TrackedOrder order = CreateTracked<TrackedOrder>();
        Console.WriteLine(order.Id);
        Console.WriteLine(order.CreatedBy);
    }

    static T CreateTracked<T>() where T : Entity, IAuditable, new()
    {
        T item = new T();
        item.Id = 500;
        item.CreatedBy = "system";
        return item;
    }
}

class Entity
{
    public int Id { get; set; }
}

interface IAuditable
{
    string CreatedBy { get; set; }
}

class TrackedOrder : Entity, IAuditable
{
    public string CreatedBy { get; set; } = "";
}

Output:

500
system

This method needs three facts: T is an Entity, implements IAuditable, and can be constructed with new T(). The order matters: base class first, interfaces after that, and new() last.

How It Works Step by Step

  1. The compiler reads the generic declaration and records each type parameter, such as T or TEntity.
  2. It reads the where clauses and attaches those requirements to the generic method, class, interface, or delegate.
  3. While compiling the generic body, it allows only operations proven by the constraints. An interface constraint permits interface members; a base class constraint permits base class members; new() permits new T().
  4. At each call site, the compiler checks the chosen type argument against the constraints. A type that fails the rule cannot be used.
  5. The compiled assembly stores the generic definition and its constraints as metadata. The CLR uses that metadata when constructing and verifying closed generic types such as Repository<Customer>.
  6. For value types, generic code can often avoid boxing because the CLR knows the exact constructed type. Constraints add type knowledge without giving up generic performance.

Constraints do not mean the generic code can do anything a particular future type might support. The body is checked once against the declared constraints. If you need a member, express it through an interface, base class, static abstract interface member, delegate parameter, or another explicit design.

Common Mistakes

Trying to Call Members Without a Constraint

static string GetName<T>(T item)
{
    return item.Name;
}

This does not compile because an unconstrained T has no guaranteed Name property. Define an interface that contains the needed member, then constrain T to that interface.

using System;

class Program
{
    static void Main()
    {
        Product product = new Product { Name = "Keyboard" };
        Console.WriteLine(GetName(product));
    }

    static string GetName<T>(T item) where T : INamed
    {
        return item.Name;
    }
}

interface INamed
{
    string Name { get; }
}

class Product : INamed
{
    public string Name { get; init; } = "";
}

Output:

Keyboard

Putting new() in the Wrong Place

class Store<T> where T : new(), IDisposable
{
}

This does not compile because new() must appear last in the constraint list. Put interface and base class constraints before it.

using System;

class Program
{
    static void Main()
    {
        Store<Connection> store = new Store<Connection>();
        Console.WriteLine(store.Create().IsOpen);
    }
}

class Store<T> where T : IDisposable, new()
{
    public T Create()
    {
        return new T();
    }
}

class Connection : IDisposable
{
    public bool IsOpen { get; } = true;

    public void Dispose()
    {
    }
}

Output:

True

Using class When You Really Mean notnull

where T : class excludes value types. That is correct when your API specifically needs reference-type behavior, but it is too narrow when you only want to avoid nullable keys or nullable values. In nullable-aware libraries, where T : notnull can accept both string and int while communicating that nullable type arguments are not intended.

Best Practices

  • Add a constraint only when the generic code truly needs the capability. Unnecessary constraints make reusable APIs less useful.
  • Prefer interface constraints for behavior, such as INamed, IComparable<T>, or IDisposable. They are more flexible than forcing a shared base class.
  • Use base class constraints when shared state or inherited implementation is genuinely part of the contract.
  • Place constraints in the required order: primary kind or base class first, interfaces next, and new() last.
  • Use struct for non-nullable value types and class for reference types. Use notnull when both are acceptable but nullable type arguments should be avoided.
  • Do not use new() for complex object creation. Prefer factories or dependency injection when construction requires parameters or services.
  • Keep type parameter names clear. Use T for a single obvious type, and names such as TEntity, TKey, or TResult when the role matters.
  • Remember that constraints are compile-time contracts, not runtime validation messages. A failed constraint is usually a compile error, not an exception.

Practice Exercises

  1. Write a method Smallest<T> that accepts two values and returns the smaller one. Constrain T with IComparable<T> and test it with int and DateTime.
  2. Create an INotifiable interface with a Send method. Write a generic method NotifyAll<T> that accepts a list of T where T : INotifiable.
  3. Build a generic ResettablePool<T> where T : IResettable, new(). It should create a new item, call Reset, and return it.

Summary

  • Generic constraints describe which types are allowed for a type parameter.
  • Constraints let the compiler safely allow members such as CompareTo, base class properties, interface methods, and new T().
  • Common constraints include class, struct, notnull, base class, interface, unmanaged, and new().
  • Invalid type arguments fail at compile time, which keeps generic APIs type-safe.
  • Use the narrowest constraint that expresses the real requirement, and prefer interfaces for behavior-focused contracts.