Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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().

  • Intermediate
  • 15 minutes
  • Examples run with .NET 10.0.401 and Python 3.14.8
  • By MySmartCoPilot
Free core, Pro depth: 6 of 15 minutes free
Free: A constraint is a promise that lets generic code use T's members · Each kind of constraint admits a different set of types. With a pass: 3 in-depth parts and 3 interview answers, about 9 minutes.

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:

unconstrained/Program.cs C# · unconstrained/Program.cs
// 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;
Building unconstrained Python · unconstrained_session.py
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: one constrained Max for six types, and MaxOf C# · max/max.cs
// 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:

Interactive · stage by stage Where the compiler applies a constraint Step through Max<T> from its body to two calls. The arrows aside are the ways out where a promise is missing.
Where the compiler applies a constraint, step 6 of 6. Showing Max<T> is compiled, Is CompareTo allowed? (yes: IComparable<T> has it), A call arrives (Max(ticket1, ticket2)), Does T keep the promise? (Ticket has no CompareTo), The result (build stops at the call). In focus: The result.no: build stopsno: build stopsMax<T> is compiledonce, for every TIs CompareTo allowed?yes: IComparable<T> has itA call arrivesMax(ticket1, ticket2)Does T keep the promise?Ticket has no CompareToThe resultbuild stops at the callthe error names the call

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. The promise holds. int implements IComparable<int>, so the constraint is met and the call compiles. max.cs printed 95.
  5. 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.
  6. 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

  1. Question 1 of 2 Inside Larger<T>(T a, T b), which has no constraint, which of these lines compiles?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: string text = a.ToString() ?? "";

    Without a constraint the compiler can assume only what every type has, the members of object, so ToString, Equals and GetHashCode compile and comparisons do not.

  2. Question 2 of 2 Why could max.cs pass two Grade records to Max<T>, whose T must implement IComparable<T>?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Grade declares that it implements IComparable<Grade> and writes CompareTo, ordering by the minimum mark

    An interface constraint admits any type that implements the interface, so your own types join a generic algorithm by implementing it.

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

  1. Question 1 of 2 A Cache<T> holds items that may be missing and uses the as operator on T. Callers pass string and Customer. Which constraint fits best?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: where T : class

    Apply the most constraints that still admit the types the generic must handle. For reference types only, class prevents misuse with value types and enables as and null checks.

  2. Question 2 of 2 CreateDefaults<T>(int count) must fill an array with count new objects by writing new T(). Which constraint makes new T() compile?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: where T : new()

    new() admits types with a public parameterless constructor, and it must be the last constraint in its where clause.

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.

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: nine checks of Repository<TEntity, TKey> C# · practice/repository.cs
// 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.

  1. Question 1 of 6 Which members can generic code call on a value of a type parameter T that has no constraint?

    Choose one answer.

    Show the answer: Question 1 of 6

    Answer: Only the members of object, such as ToString, Equals and GetHashCode

    A constraint is how generic code gains more: each where clause adds the members of an interface or a base class, or an ability such as new T().

  2. Question 2 of 6 Max<T>(T a, T b) where T : IComparable<T> is called as Max(3, 7.5). What happens?

    Choose one answer.

    Show the answer: Question 2 of 6

    Answer: T is inferred as double, 3 is converted, and double meets the constraint, so the result is 7.5

    Type inference runs first, then the constraint is checked for the type it chose. Here T is double, so Max<double> compares 3.0 and 7.5.

  3. Question 3 of 6 A library adds where T : IComparable<T> to a public generic method that had no constraint. Which code can stop compiling?

    Choose one answer.

    Show the answer: Question 3 of 6

    Answer: Callers that pass a type argument that does not implement IComparable<T>

    Adding a constraint narrows the set of types callers may use, so it can break them; removing one can break the body. Treat both as API changes.

  4. Question 4 of 6 Which of these can be a type argument for where T : unmanaged?

    Choose one answer.

    Show the answer: Question 4 of 6

    Answer: record struct Reading(int Sensor, float Celsius)

    An unmanaged type has no references anywhere inside it, which is what lets generic code treat its value as a block of bytes.

  5. Question 5 of 6 Cache<TKey> where TKey : notnull is used as Cache<string?> in a project with nullable enabled. What does the compiler do?

    Choose one answer.

    Show the answer: Question 5 of 6

    Answer: It builds and reports a warning, because notnull is the one constraint whose violations are warnings

    notnull works with nullable reference types: it is enforced as a warning in a nullable context and not at all in an oblivious one.

  6. Question 6 of 6 Which where clause is written in a valid order and combination?

    Choose one answer.

    Show the answer: Question 6 of 6

    Answer: where T : Payment, IComparable<T>, new()

    One of class, struct, notnull and unmanaged, or else a base class, comes first, then interfaces, then new(); and a few pairs, such as struct with new() or class with a base class, are not allowed at all.

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.

  1. 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.

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. 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.

    Model answer, Pro. First step and follow-ups

  3. 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?

    Model answer, Pro. First step and follow-ups

Key takeaways

  • Without a constraint, generic code may use only the members of object on T; a where clause promises more, and the compiler checks the body against it once and every call against it again.
  • where T : IComparable<T> lets one Max serve every comparable type, int and Version and 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, Enum and Delegate, and allows ref struct as 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

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.