Advanced Generics

By the Syncfusion DevTutorials team · Last updated

Generic constraints with where: new(), interface and base class constraints, multiple constraints, generic math with INumber<T>, and generic performance.

Introduction to Advanced Generics

Think about a lunch box with a label: "Sandwiches only".

A plain box can hold anything: soup, sand, a sandwich. A labelled box makes a promise. Everyone who opens it knows exactly what's inside and what they can do with it.

You already know generics: List<int>, Dictionary<string, decimal>, or your own Box<T>. The T is a placeholder for "any type".

But sometimes you need to limit which types T can be. If your method needs to compare two values, or create a new one, or call .Save(), then not every type will work. Generic constraints are like the label on the box: they limit which types are allowed, and in return they let you use those types' abilities.

In simple words: a constraint is a rule for T. It limits which types can be used, and unlocks what you can do with them.

Easy Way to Remember: without a constraint, T can only do what every object can do. A constraint is a promise that unlocks more.

What You Will Learn

  1. What generic constraints are, and the where keyword.
  2. The new() constraint, for creating objects.
  3. Interface constraints and base class constraints.
  4. Combining several constraints.
  5. Generic math with INumber<T>.
  6. How generics affect performance.

All examples in this lesson run in your browser. Generic math needs .NET 7 or later; the playground uses .NET 10.

Start With a Working Example

Press Run.

Find the largest item of any comparable type

using System;

Console.WriteLine(Largest(new[] { 4, 19, 7 }));
Console.WriteLine(Largest(new[] { "pear", "apple", "zebra", "mango" }));
Console.WriteLine(Largest(new[] { 2.5, 9.75, 3.0 }));

static T Largest<T>(T[] items) where T : IComparable<T>
{
    T best = items[0];
    foreach (T item in items)
    {
        if (item.CompareTo(best) > 0)
            best = item;
    }
    return best;
}

One method works for numbers, text and decimals. where T : IComparable<T> promises that every T has a CompareTo method, so the method is allowed to call it.

Try This

Remove where T : IComparable<T> and run it again. The compiler now refuses item.CompareTo(best), because it can no longer be sure that T knows how to compare itself.

Generic Constraints

A constraint is written with the where keyword after the method or class signature:

static void Save<T>(T item) where T : ISaveable
class Repository<T> where T : class, IEntity, new()

Here are all the common constraints:

ConstraintT must beUnlocks
where T : classA reference type: a class, interface, delegate, or array. These are types that are stored on the heap with a memory address.Using null checks and reference semantics
where T : class?A reference type, but nullable types are also allowedSame as above, with support for nullable types
where T : structA non-nullable value type, like int, DateTime, or your own struct. Value types are stored directly on the stack.Value-type behavior: the type is copied when passed to a method or assigned to a variable
where T : notnullAny type that cannot be nullSafe to use as a dictionary key
where T : unmanagedA value type with no references inside itLow-level memory operations and unsafe code
where T : new()A type with a public parameterless constructorUsing new T() to create objects
where T : SomeBaseClassThat class or a class derived from itCalling the base class's methods and properties
where T : ISomeInterfaceA type that implements the interfaceCalling all the interface's methods and properties
where T : UThe same as, or derived from, another type parameter UConverting T to U

class and struct constraints

using System;

Console.WriteLine(Describe("hello"));      // string is a class
Console.WriteLine(Describe(new int[3]));  // arrays are classes
Console.WriteLine(Twice(21));              // int is a struct
Console.WriteLine(Twice(DateTime.MinValue.Year));

static string Describe<T>(T? item) where T : class =>
    item is null ? "nothing" : $"a {typeof(T).Name}";

static string Twice<T>(T value) where T : struct =>
    $"{value} and {value}";

If you call Describe(5), the compiler stops you: int is not a class. Constraints catch mistakes at compile time, before the program ever runs.

new() Constraint

The new() constraint says: "T must have a public constructor with no parameters". In return, you can write new T() inside the generic code.

It is useful for factories and for code that needs to create fresh objects of a type it does not know in advance.

A generic factory with new()

using System;
using System.Collections.Generic;

List<Customer> customers = CreateMany<Customer>(3);
List<Order> orders = CreateMany<Order>(2);

Console.WriteLine($"Made {customers.Count} customers, first is '{customers[0].Name}'");
Console.WriteLine($"Made {orders.Count} orders, status '{orders[0].Status}'");

static List<T> CreateMany<T>(int count) where T : new()
{
    var list = new List<T>();
    for (int i = 0; i < count; i++)
        list.Add(new T());   // allowed because of new()
    return list;
}

class Customer
{
    public string Name { get; set; } = "New customer";
}

class Order
{
    public string Status { get; set; } = "Pending";
}

A class whose only constructor takes parameters, such as class Point(int x, int y), cannot be used here. The compiler would reject CreateMany<Point>(2).

Interface Constraints

An interface constraint says: "T must implement this interface". Inside the generic code you can then call every member of that interface.

This is the most common kind of constraint. It lets one method work with many different classes, as long as they all follow the same contract.

Print a total for anything that has a price

using System;
using System.Collections.Generic;

var books = new List<Book> { new("C# Basics", 450m), new("Async Deep Dive", 700m) };
var courses = new List<Course> { new("Generics", 1200m) };

Console.WriteLine($"Books:   {Total(books)}");
Console.WriteLine($"Courses: {Total(courses)}");

static decimal Total<T>(IEnumerable<T> items) where T : IPriced
{
    decimal sum = 0;
    foreach (T item in items)
        sum += item.Price;     // IPriced guarantees Price exists
    return sum;
}

interface IPriced
{
    decimal Price { get; }
}

record Book(string Title, decimal Price) : IPriced;
record Course(string Name, decimal Price) : IPriced;

Book and Course are unrelated types, but both implement IPriced, so Total works with both.

Base Class Constraints

A base class constraint says: "T must be this class, or a class that inherits from it". You can then use everything the base class offers.

A pay report for any kind of employee

using System;
using System.Collections.Generic;

var developers = new List<Developer> { new("Asha", 90_000), new("Ravi", 85_000) };
var managers = new List<Manager> { new("Meena", 120_000) };

PrintPay(developers);
PrintPay(managers);

static void PrintPay<T>(List<T> staff) where T : Employee
{
    foreach (T person in staff)
        Console.WriteLine($"{person.Name,-6} {person.Role,-10} {person.MonthlyPay():0}");
}

abstract class Employee(string name, decimal yearlySalary)
{
    public string Name { get; } = name;
    public abstract string Role { get; }
    public virtual decimal MonthlyPay() => yearlySalary / 12;
}

class Developer(string name, decimal salary) : Employee(name, salary)
{
    public override string Role => "Developer";
}

class Manager(string name, decimal salary) : Employee(name, salary)
{
    public override string Role => "Manager";
    public override decimal MonthlyPay() => base.MonthlyPay() + 1000; // bonus
}
Interface constraintBase class constraint
How many per TAs many as you likeOnly one
Shares codeNo, only a contractYes, the base class's code
Typical useBehavior unrelated types share (IComparable, IPriced)A family of related classes (Employee, Shape)

Multiple Constraints

One type parameter can have several constraints, separated by commas. They must come in this order:

  1. class, struct, notnull or unmanaged (only one of these)
  2. a base class (only one)
  3. any number of interfaces
  4. new() (always last)

Each type parameter gets its own where clause.

class Repository<TEntity, TKey>
    where TEntity : class, IEntity<TKey>, new()
    where TKey : notnull, IComparable<TKey>

A small repository with several constraints

using System;
using System.Collections.Generic;
using System.Linq;

var repo = new Repository<Product, int>();
repo.Add(new Product { Id = 3, Name = "Lamp" });
repo.Add(new Product { Id = 1, Name = "Desk" });
Product blank = repo.CreateBlank();

Console.WriteLine($"Blank product: '{blank.Name}'");
foreach (Product p in repo.AllSorted())
    Console.WriteLine($"{p.Id}: {p.Name}");

class Repository<TEntity, TKey>
    where TEntity : class, IEntity<TKey>, new()
    where TKey : notnull, IComparable<TKey>
{
    private readonly Dictionary<TKey, TEntity> _items = new();

    public void Add(TEntity entity) => _items[entity.Id] = entity;   // TKey is notnull: safe as a key
    public TEntity CreateBlank() => new TEntity();                   // thanks to new()
    public IEnumerable<TEntity> AllSorted() =>
        _items.Values.OrderBy(e => e.Id);                             // TKey is IComparable
}

interface IEntity<TKey>
{
    TKey Id { get; }
}

class Product : IEntity<int>
{
    public int Id { get; set; }
    public string Name { get; set; } = "Unnamed";
}

Every constraint earns its place: class because entities are reference types, IEntity<TKey> for Id, new() for CreateBlank, notnull for the dictionary key, IComparable<TKey> for sorting.

Add only the constraints your code really uses. Each extra constraint shuts out some types for no reason, which makes the generic code less reusable.

Generic Math

Before .NET 7, you could not write one Sum<T> method for int, double and decimal. There was no constraint that meant "a number", so a + b did not compile for a T.

Generic math fixes this. .NET 7 added interfaces such as INumber<T> (in System.Numerics) that every built-in number type implements. They provide operators like +, -, *, /, <, and values like T.Zero and T.One.

One Sum and Average for every number type

using System;
using System.Numerics;

int[] marks = { 72, 85, 91 };
double[] heights = { 1.62, 1.75, 1.80 };
decimal[] prices = { 19.99m, 5.01m, 75m };

Console.WriteLine($"Sum of marks:   {Sum(marks)}");
Console.WriteLine($"Sum of heights: {Sum(heights)}");
Console.WriteLine($"Sum of prices:  {Sum(prices)}");
Console.WriteLine($"Average price:  {Average(prices):0.00}");

static T Sum<T>(T[] values) where T : INumber<T>
{
    T total = T.Zero;           // the zero of whatever number type T is
    foreach (T v in values)
        total += v;             // + works because of INumber<T>
    return total;
}

static T Average<T>(T[] values) where T : INumber<T> =>
    Sum(values) / T.CreateChecked(values.Length);

T.CreateChecked(values.Length) turns the int length into a T, so it can be divided. Some useful generic math interfaces:

InterfaceGives you
INumber<T>Everything for ordinary numbers: + - * /, comparisons, T.Zero, T.One
IAdditionOperators<T,T,T>Only + (when that is all you need)
IComparisonOperators<T,T,bool><, >, <=, >=
IBinaryInteger<T>Whole-number types only: %, bit operations
IFloatingPoint<T>double, float, decimal: rounding, fractions

Interfaces like INumber<T> use static abstract members. This means that T.Zero and the + operator are static—you call them directly on the type, not on an instance. This feature helps generic math work smoothly across all number types.

Generic Performance Considerations

Generics are not just convenient. They are usually faster than the old non-generic alternatives.

Boxing. Storing an int in an object variable (as old collections like ArrayList did) wraps the number in a container object on the heap. To use the number, you must unwrap it. This wrapping and unwrapping costs memory and processing time.

Generic collections like List<int> store the int directly: no wrapping, less memory, less garbage collection work.

List<int> versus List<object>

using System;
using System.Collections.Generic;

var typed = new List<int>();
var untyped = new List<object>();

for (int i = 0; i < 5; i++)
{
    typed.Add(i);     // stored as an int
    untyped.Add(i);   // wrapped: stored in a container object
}

int sumTyped = 0;
foreach (int n in typed) sumTyped += n;              // no cast needed

int sumUntyped = 0;
foreach (object o in untyped) sumUntyped += (int)o;  // unwrap (cast) every time

Console.WriteLine($"Typed sum:   {sumTyped}");
Console.WriteLine($"Untyped sum: {sumUntyped}");
Console.WriteLine($"Element type of the typed list: {typed.GetType().GenericTypeArguments[0].Name}");
PointWhat it means for you
No boxing for value typesList<int>, Dictionary<int, double> store numbers directly without wrapping them, making them fast and memory-friendly
Specialised code per value type.NET builds separate optimised code for List<int> and List<double>, so each collection is as fast as possible for its type
Shared code for reference typesList<string> and List<Customer> share one copy of the code, so generic code does not make your program bigger
Type safety at compile timeWrong types are caught before running, with no runtime casts needed
Constraints cost nothing at run timeConstraints are checked by the compiler only; they do not slow down your running program
Avoid typeof(T) == typeof(int) checks in loopsUse method overloads or constraints instead; type checks slow down frequently-called code

Calling an interface method on a constrained T that is a struct does not wrap it. But casting that struct to the interface type itself, such as IComparable c = value;, does wrap it. Keep the value typed as T.

Common mistakes

MistakeWrongCorrectWhy
Calling a method T might not haveitem.CompareTo(x) with no constraintwhere T : IComparable<T>T only has object's members
new T() without new()T x = new T();where T : new()The compiler cannot know a constructor exists
new() in the wrong placewhere T : new(), IEntitywhere T : IEntity, new()new() must come last
Two base classeswhere T : Animal, VehicleOne base class, many interfacesC# allows only one base class
+ on a plain TT total = a + b;where T : INumber<T>Operators need generic math
Starting a sum at 0T total = 0;T total = T.Zero;0 is an int, not a T
Boxing with object listsList<object> for numbersList<int>Boxing costs memory and time

What to Remember

  • Generic constraints use where to limit which types T can be, and unlock what you can do with T.
  • class, struct, notnull and unmanaged describe the kind of type.
  • new() allows new T(); it must be the last constraint.
  • An interface constraint gives access to the interface's members; a type can have many.
  • A base class constraint gives access to the base class's members; only one is allowed.
  • Several constraints go in order: kind, base class, interfaces, new().
  • Generic math (INumber<T>, T.Zero, T.CreateChecked) lets one method do maths on every number type.
  • Generic collections avoid boxing and catch type mistakes at compile time.

Next: Extension Methods