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
- What generic constraints are, and the
wherekeyword. - The
new()constraint, for creating objects. - Interface constraints and base class constraints.
- Combining several constraints.
- Generic math with
INumber<T>. - 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:
| Constraint | T must be | Unlocks |
|---|---|---|
where T : class | A 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 allowed | Same as above, with support for nullable types |
where T : struct | A 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 : notnull | Any type that cannot be null | Safe to use as a dictionary key |
where T : unmanaged | A value type with no references inside it | Low-level memory operations and unsafe code |
where T : new() | A type with a public parameterless constructor | Using new T() to create objects |
where T : SomeBaseClass | That class or a class derived from it | Calling the base class's methods and properties |
where T : ISomeInterface | A type that implements the interface | Calling all the interface's methods and properties |
where T : U | The same as, or derived from, another type parameter U | Converting 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 constraint | Base class constraint | |
|---|---|---|
How many per T | As many as you like | Only one |
| Shares code | No, only a contract | Yes, the base class's code |
| Typical use | Behavior 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:
class,struct,notnullorunmanaged(only one of these)- a base class (only one)
- any number of interfaces
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:
| Interface | Gives 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}");
| Point | What it means for you |
|---|---|
| No boxing for value types | List<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 types | List<string> and List<Customer> share one copy of the code, so generic code does not make your program bigger |
| Type safety at compile time | Wrong types are caught before running, with no runtime casts needed |
| Constraints cost nothing at run time | Constraints are checked by the compiler only; they do not slow down your running program |
Avoid typeof(T) == typeof(int) checks in loops | Use 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
| Mistake | Wrong | Correct | Why |
|---|---|---|---|
Calling a method T might not have | item.CompareTo(x) with no constraint | where 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 place | where T : new(), IEntity | where T : IEntity, new() | new() must come last |
| Two base classes | where T : Animal, Vehicle | One base class, many interfaces | C# allows only one base class |
+ on a plain T | T total = a + b; | where T : INumber<T> | Operators need generic math |
Starting a sum at 0 | T total = 0; | T total = T.Zero; | 0 is an int, not a T |
Boxing with object lists | List<object> for numbers | List<int> | Boxing costs memory and time |
What to Remember
- Generic constraints use
whereto limit which typesTcan be, and unlock what you can do withT. class,struct,notnullandunmanageddescribe the kind of type.new()allowsnew 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