C# & .NET (C# 14, .NET 10 LTS) Module 11 – Generics
Generic constraints
Constrain C# type parameters with where, call members of T through an interface constraint, and choose between class, struct, notnull, unmanaged and new().
What you will learn
- Call members of T through an interface constraint such as IComparable<T>
- Limit type parameters with class, struct, notnull, unmanaged, new() and base-class constraints
- Read and fix the compiler errors that report a broken constraint
- Predict what T? and == mean under each kind of constraint
- Write Enum, unmanaged and allows ref struct helpers that the compiler keeps safe
Before you start
What you’ll learn · Before you start
What you will learn
- Call members of T through an interface constraint such as IComparable<T>
- Limit type parameters with class, struct, notnull, unmanaged, new() and base-class constraints
- Read and fix the compiler errors that report a broken constraint
- Predict what T? and == mean under each kind of constraint
- Write Enum, unmanaged and allows ref struct helpers that the compiler keeps safe
Before you start
On this page
Max<T>(T a, T b) that compares with a > b does not build: on .NET 10 the compiler reported error CS0019 for >
and an unrelated error for a.CompareTo(b). A type parameter with no constraint could be any type at all, so generic
code may use only what every type has, the members of object. One clause, where T : IComparable<T>, fixes both
lines and lets the same method compare marks, names, dates and versions.
You will write constraints that let generic code call members of T, choose among the kinds of constraint, and see
which types each one admits.
A constraint is a promise that lets generic code use T’s members
A constraint: A rule in a where clause, such as where T : IComparable<T>, that limits the types a type parameter accepts. In return, generic code may use what the rule promises, such as CompareTo or new T(). is a promise about the type argument, written in a where clause.
Inside the generic code, T may then use what the constraint promises. Without one, T is an
unbounded type parameter: A type parameter with no constraint. Generic code may use only the members of object on its values, and may not compare two of them with == or !=, because the type argument could be any type., and Microsoft’s guide lists what is left: no ==
or != between two values of T, and only the members of object. Both attempts below fail for that reason:
// Two ways to write Max without a constraint. The compiler knows nothing about T except that it is an object.
Console.WriteLine(Max(72, 95));
Console.WriteLine(Larger(72, 95));
static T Max<T>(T a, T b) => a > b ? a : b;
static T Larger<T>(T a, T b) => a.CompareTo(b) >= 0 ? a : b; from session import run
# unconstrained compares two values of an unconstrained T, once with > and once with CompareTo. The build stops.
run("""
cd unconstrained
!dotnet run
""") Output
$ cd unconstrained $ dotnet run unconstrained/Program.cs(5,30): error CS0019: Operator '>' cannot be applied to operands of type 'T' and 'T' [unconstrained/App.csproj] unconstrained/Program.cs(7,35): error CS7036: There is no argument given that corresponds to the required parameter 'comparisonType' of 'MemoryExtensions.CompareTo(ReadOnlySpan<char>, ReadOnlySpan<char>, StringComparison)' [unconstrained/App.csproj] The build failed. Fix the build errors and run again. [exit status 1]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 unconstrained_session.py
CS0019 says that > means nothing for an arbitrary T. The second error looks unrelated: T has no CompareTo of
its own, so the compiler went looking for an extension method of that name, and the only candidate it found, the one
for character spans, needs a third argument. With where T : IComparable<T>, every type argument must implement the
interface, so a.CompareTo(b) is allowed. max.cs uses the same method for int, string, decimal, DateOnly,
Version and a record of its own, and MaxOf puts the same constraint on a whole list:
// max.cs: where T : IComparable<T> promises that every T has CompareTo(T), so Max may call it. Any type that keeps the
// promise works, the BCL's own and yours.
Console.WriteLine(Max(72, 95));
Console.WriteLine(Max("Kochi", "Indore"));
Console.WriteLine(Max(12.5m, 9.75m));
Console.WriteLine(Max(new DateOnly(2026, 3, 9), new DateOnly(2026, 3, 2)).ToString("yyyy-MM-dd"));
Console.WriteLine(Max(new Version(10, 0, 401), new Version(9, 0, 304)));
Console.WriteLine(Max(new Grade("B", 70), new Grade("A", 85)));
Console.WriteLine(MaxOf([41, 87, 63, 87, 12]));
static T Max<T>(T a, T b)
where T : IComparable<T> => a.CompareTo(b) >= 0 ? a : b;
static T MaxOf<T>(IReadOnlyList<T> items)
where T : IComparable<T>
{
if (items.Count == 0)
{
throw new ArgumentException("no items to compare", nameof(items));
}
T best = items[0];
for (int i = 1; i < items.Count; i++)
{
if (items[i].CompareTo(best) > 0)
{
best = items[i];
}
}
return best;
}
// A grade is ordered by its minimum mark.
sealed record Grade(string Letter, int MinimumMark) : IComparable<Grade>
{
public int CompareTo(Grade? other) => other is null ? 1 : MinimumMark.CompareTo(other.MinimumMark);
} Output
95
Kochi
12.5
2026-03-09
10.0.401
Grade { Letter = A, MinimumMark = 85 }
87
Recorded with .NET SDK 10.0.401 (runtime 10.0.12) on macOS 26 arm64. To run it yourself: dotnet run max.cs
The first six lines called the same method, written once. Grade takes part because it implements
IComparable<Grade>, ordering grades by their minimum mark. Step through the two places where the compiler applies
the constraint:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- The body first. The compiler checks the body of Max<T> once, for every T at the same time. Inside it, T has only the members of object and what its constraints promise.
- CompareTo is promised. where T : IComparable<T> promises a CompareTo(T) method, so a.CompareTo(b) compiles. Without the constraint the same idea failed: the unconstrained build stopped at both > and CompareTo.
- A call arrives. Max(72, 95) is the first call of max.cs. The compiler infers T as int from the two arguments, as it does for any generic method.
- The promise holds. int implements IComparable<int>, so the constraint is met and the call compiles. max.cs printed 95.
- Another call. A call with two objects of a class that does not implement IComparable<T> is checked the same way. This time the promise is missing, and the build stops at the call itself.
- Each call is checked. The body was checked once and stays valid; only the type arguments of each call are checked again. A broken constraint is always reported where the generic is used.
Key idea
A constraint is checked twice: once in the generic body, which may use only what the constraint promises, and again at every call, whose type argument must keep the promise.
Checkpoint
Check: Constraints and the members of T
Each kind of constraint admits a different set of types
An interface constraint is the one you write most, but C# has a kind for each common need. Microsoft’s guide sets
the order: one of class, struct, notnull and unmanaged, or else a base class, comes first, then interfaces,
then new(), and allows ref struct after all of them. The table lists what each kind admits, and what it lets the
code that uses T do:
| Constraint | Type arguments it admits | What generic code gains |
|---|---|---|
where T : IComparable<T> |
types that implement the interface | the interface’s members, such as CompareTo |
where T : Payment |
the class Payment and classes derived from it |
the members of Payment |
where T : class |
reference types | the as operator, and null for a missing value |
where T : struct |
non-nullable value types | T? as Nullable<T>; no null at all |
where T : notnull |
non-nullable value and reference types | a warning when a nullable type is passed |
where T : unmanaged |
structs with no references, at any depth | reading T as a block of bytes |
where T : new() |
types with a public parameterless constructor | new T() |
where T : U |
U or a type derived from it |
converting T to U |
where T : struct, Enum |
enum types | the generic Enum methods, without boxing |
where T : Delegate |
delegate types, such as Action |
the members of Delegate, while T keeps its exact type |
where T : default |
any type, on overrides only | matching the base method without class or struct |
where T : allows ref struct |
also ref structs, such as Span<T> |
generic code over spans |
The unmanaged type: A non-nullable value type whose fields, at every level of nesting, are unmanaged too, such as int, double or a struct of them. Holding no references, its value can be handled as a block of bytes. constraint admits a struct only when every field, at every level of
nesting, is unmanaged too. notnull is the odd one: a violation is a warning, not an error. And allows ref struct,
added in C# 13, is an anti-constraint: A clause that widens the types a type parameter accepts instead of narrowing them. allows ref struct, added in C# 13, lets ref struct types such as Span<T> be type arguments.: it widens the set of types instead of
narrowing it. Several clauses combine, as in where T : Payment, IComparable<T>, new(), and each type
parameter gets its own where.
Microsoft’s guide on generic classes gives the rule for choosing: apply the most constraints that still let you handle the types you must handle. Each one you add rules out callers, and each one you leave out rules out code.
Checkpoint
Check: The kinds of constraint and what each admits
Constraint errors reproduced, with the fix for each Pro: opens with a Pro, Premium or Ultimate pass
Every broken constraint stops the build with a message that names the type argument, the type parameter and the generic. The builds below break six constraints on purpose, then show why two methods cannot differ only in their constraints and what a derived class must repeat.
In this part:
- Six broken constraints in one build, and the fix for each
- Constraints are not part of a method’s signature
2 runnable examples (C#, Python) · about 4 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
T? and == under each constraint, with the bugs they cause Pro: opens with a Pro, Premium or Ultimate pass
Two pieces of syntax change their meaning with the constraint on T: the ? of T? and the == operator. The runs
below show a lookup that cannot say “not found” for numbers, and a comparison that calls two equal strings different.
In this part:
- What T? means for an unconstrained, a struct and a class T
- Equality on a class-constrained T: references, not values
2 runnable examples (C#) · about 4 minutes
Opening this part with your pass…Pro part. It opens with the same pass as the part above.
Enum, unmanaged, new(), Delegate and allows ref struct in real helpers Pro: opens with a Pro, Premium or Ultimate pass
Five constraints admit special kinds of type: enums, plain structs, types with a parameterless constructor, delegates and ref structs. Each appears below in a small helper of the kind real code needs, with the trap it avoids.
In this part:
- Enum helpers that refuse undefined values
- Reading the bytes of any unmanaged struct, with no unsafe code
- new(), Delegate and allows ref struct
3 runnable examples (C#, Python) · about 4 minutes
Opening this part with your pass…Pro part. It opens with the same pass as the part above.
Practise: a repository for any key type
repository.cs checks a Repository<TEntity, TKey> that stores entities by their Id, with int, string and
Guid keys. The starter has the right members but no constraint on TKey: it compares keys with object.Equals,
which boxes every int, and it accepts two entities with the same Id. It passes 5 of the 9 checks:
// repository.cs: practice. Repository<TEntity, TKey> stores entities by their Id. Give TKey the constraints a key
// needs, refuse a second entity with an Id already stored, and look keys up without boxing them.
// Rewrite Repository<TEntity, TKey> at the end of this file until all nine checks pass.
using System.Diagnostics.CodeAnalysis;
(string Check, Func<string> Run, string Expected)[] checks =
[
("an int key finds its customer", () => Name(Customers(), 102), "Ravi"),
("a missing key finds nothing", () => Name(Customers(), 999), "(none)"),
("string keys compare by their text", () => City(Bookings(), string.Concat("PNR", "4522")), "Kochi"),
("Guid keys work", () => GuidKeys(), "found"),
("a second entity with the same Id is refused", () => Throws(() => Customers().Add(new Customer(101, "Meera"))), "ArgumentException"),
("Count counts each Id once", () => CountAfterDuplicate(), "3"),
("Remove takes an entity out once", () => RemoveTwice(), "True, False"),
("TKey is constrained to IEquatable<TKey>", KeyConstraint, "yes"),
("1,000 lookups of an int key allocate nothing", BytesFor1000Lookups, "0 bytes"),
];
int passed = 0;
foreach (var (check, run, expected) in checks)
{
string got;
try
{
got = run();
}
catch (Exception e)
{
got = $"throws {e.GetType().Name}";
}
bool ok = got == expected;
if (ok)
{
passed++;
}
Console.WriteLine(ok ? $"{check}: ok" : $"{check}: FAIL, got {got}, expected {expected}");
}
Console.WriteLine($"{passed} of {checks.Length} checks pass");
return passed == checks.Length ? 0 : 1;
static Repository<Customer, int> Customers()
{
var customers = new Repository<Customer, int>();
customers.Add(new Customer(101, "Asha"));
customers.Add(new Customer(102, "Ravi"));
customers.Add(new Customer(103, "Imran"));
return customers;
}
static Repository<Booking, string> Bookings()
{
var bookings = new Repository<Booking, string>();
bookings.Add(new Booking("PNR4521", "Pune"));
bookings.Add(new Booking("PNR4522", "Kochi"));
return bookings;
}
static string Name(Repository<Customer, int> customers, int id) =>
customers.TryGet(id, out Customer? customer) ? customer.Name : "(none)";
static string City(Repository<Booking, string> bookings, string pnr) =>
bookings.TryGet(pnr, out Booking? booking) ? booking.City : "(none)";
static string GuidKeys()
{
Guid id = Guid.Parse("6f9619ff-8b86-d011-b42d-00c04fc964ff");
var devices = new Repository<Device, Guid>();
devices.Add(new Device(id, "scanner"));
return devices.TryGet(Guid.Parse("6F9619FF-8B86-D011-B42D-00C04FC964FF"), out _) ? "found" : "missing";
}
static string CountAfterDuplicate()
{
var customers = Customers();
try
{
customers.Add(new Customer(101, "Meera"));
}
catch (ArgumentException)
{
}
return $"{customers.Count}";
}
static string RemoveTwice()
{
var customers = Customers();
bool first = customers.Remove(102);
bool second = customers.Remove(102);
return $"{first}, {second}";
}
static string Throws(Action action)
{
try
{
action();
return "no exception";
}
catch (Exception e)
{
return e.GetType().Name;
}
}
static string KeyConstraint()
{
Type key = typeof(Repository<,>).GetGenericArguments()[1];
bool equatable = key.GetGenericParameterConstraints()
.Any(c => c.IsGenericType && c.GetGenericTypeDefinition() == typeof(IEquatable<>));
return equatable ? "yes" : "no";
}
static string BytesFor1000Lookups()
{
var customers = Customers();
customers.TryGet(101, out _);
int found = 0;
long before = GC.GetAllocatedBytesForCurrentThread();
for (int i = 0; i < 1_000; i++)
{
if (customers.TryGet(101 + i % 3, out _))
{
found++;
}
}
long bytes = GC.GetAllocatedBytesForCurrentThread() - before;
return found == 1_000 ? $"{bytes} bytes" : $"found {found} of 1000";
}
interface IEntity<TKey>
{
TKey Id { get; }
}
sealed record Customer(int Id, string Name) : IEntity<int>;
sealed record Booking(string Id, string City) : IEntity<string>;
sealed record Device(Guid Id, string Kind) : IEntity<Guid>;
// The starter scans a list and compares keys with object.Equals, which boxes an int key at every comparison, and it
// accepts any number of entities with the same Id.
sealed class Repository<TEntity, TKey>
where TEntity : IEntity<TKey>
{
private readonly List<TEntity> _items = [];
public int Count => _items.Count;
public void Add(TEntity entity) => _items.Add(entity);
public bool TryGet(TKey id, [MaybeNullWhen(false)] out TEntity entity)
{
foreach (TEntity item in _items)
{
if (Equals(item.Id, id))
{
entity = item;
return true;
}
}
entity = default;
return false;
}
public bool Remove(TKey id) => _items.RemoveAll(item => Equals(item.Id, id)) > 0;
} Output (exit status 1)
an int key finds its customer: ok a missing key finds nothing: ok string keys compare by their text: ok Guid keys work: ok a second entity with the same Id is refused: FAIL, got no exception, expected ArgumentException Count counts each Id once: FAIL, got 4, expected 3 Remove takes an entity out once: ok TKey is constrained to IEquatable<TKey>: FAIL, got no, expected yes 1,000 lookups of an int key allocate nothing: FAIL, got 95952 bytes, expected 0 bytes 5 of 9 checks pass
Recorded with .NET SDK 10.0.401 (runtime 10.0.12) on macOS 26 arm64. To run it yourself: dotnet run repository.cs
Constrain the key, then use a dictionary
Copy repository.cs. Add where TKey : notnull, IEquatable<TKey>, keep the entities in a
Dictionary<TKey, TEntity>, and throw ArgumentException from Add when the Id is already stored. Run
dotnet run repository.cs until it prints 9 of 9 checks pass.
Then check what you know about the ideas of the first two sections:
Practice that adapts to you
Practise this lesson
6 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Fresher · Why does a generic Max<T> need a constraint, and which one?
Inside generic code, a type parameter without constraints could be any type, so the compiler allows only what every
type has: the members of object. Comparing two values with > or calling CompareTo is therefore an error. The
constraint where T : IComparable<T> promises that every type argument has a CompareTo(T) method, so Max can call
it, and the compiler checks the promise at every call. int, string, decimal, DateOnly and your own types that
implement the interface all work with one method; a type that does not implement it is refused when the program is
compiled, not when it runs.
-
Mid to senior
A Cache<TKey, TValue> where TKey : class finds keys with ==. Keys typed in the code are found, but the same keys read from a file are always missing. Explain the bug, and rewrite the lookup so that it works for any key type, int keys included.
-
Mid to senior
You want Format<T>(T value) to behave one way for structs and another for classes, so you declare one overload with where T : struct and one with where T : class. The compiler rejects the second with CS0111. Explain why, and give two designs that achieve both behaviours.
-
Fresher to mid
Design the constraints of a Repository<TEntity, TKey> that must work with int, string and Guid keys. Which constraints do you put on TKey and TEntity, and what does each one give you when the code compiles and when it runs?
Key takeaways
- Without a constraint, generic code may use only the members of
objectonT; awhereclause promises more, and the compiler checks the body against it once and every call against it again. where T : IComparable<T>lets oneMaxserve every comparable type,intandVersionand your own records alike.- Each kind of constraint admits its own set of types: an interface or a base class,
class,struct,notnull,unmanaged,new(), another type parameter,EnumandDelegate, andallows ref structas the one that widens.
Terms in this lesson
- anti-constraint
- A clause that widens the types a type parameter accepts instead of narrowing them. allows ref struct, added in C# 13, lets ref struct types such as Span<T> be type arguments.
- constraint
- A rule in a where clause, such as where T : IComparable<T>, that limits the types a type parameter accepts. In return, generic code may use what the rule promises, such as CompareTo or new T().
- unbounded type parameter
- A type parameter with no constraint. Generic code may use only the members of object on its values, and may not compare two of them with == or !=, because the type argument could be any type.
- unmanaged type
- A non-nullable value type whose fields, at every level of nesting, are unmanaged too, such as int, double or a struct of them. Holding no references, its value can be handled as a block of bytes.
References
- Constraints on type parameters (Microsoft Learn)
- where (generic type constraint) (Microsoft Learn)
- Resolve errors and warnings related to generic type parameters and type arguments (Microsoft Learn)
- What's new in C# 13 (Microsoft Learn)
- C# language version history (.NET Foundation (dotnet/csharplang))
- Generic classes (Microsoft Learn)
- IComparable<T> interface (Microsoft Learn)
- Enum.IsDefined method (Microsoft Learn)
- Enum.TryParse method (Microsoft Learn)
- Func<T,TResult> delegate (Microsoft Learn)
- MemoryMarshal.AsBytes method (Microsoft Learn)
- EqualityComparer<T>.Default property (Microsoft Learn)
- C# language specification: basic concepts (signatures and overloading) (Microsoft Learn (ECMA-334 draft))
Report a problem with this lesson
Kept only in this browser. Your Learn progress