AlgoMaster Logo

Generic Constraints

Medium Priority31 min readUpdated June 6, 2026

A generic type parameter without constraints is almost useless inside the method body. You can't call Compare, you can't write new T(), you can't even call .Length. Constraints are the where clauses that tell the compiler "this T is at least this kind of thing", which unlocks the operations that kind of thing supports. This lesson covers every standard constraint, what each one unlocks, the rules for combining them, and the compiler errors that show up when you get them wrong.

Why Constraints Exist

When you write class Repository<T> with no constraint, the compiler treats T as if it were object. That's the safest assumption it can make, because any concrete type you eventually plug in derives from object. The downside is that object has almost no useful members: ToString, GetHashCode, Equals, GetType. Nothing about ordering, no parameterless constructor guarantee, no specific properties.

Neither line compiles. The compiler doesn't know whether T has a parameterless constructor, and it doesn't know whether T implements IComparable<T>. From its point of view, T could be int, string, Stream, or some sealed class with no public constructor. It refuses to assume.

Constraints fix this by narrowing what T can be. Each constraint is a promise: "callers, you can only plug in types that satisfy this rule." In exchange, the compiler lets you use the operations that rule guarantees.

The where T : new() clause is the contract. The repository now refuses to compile if a caller writes new Repository<Stream>(), because Stream is abstract and has no public parameterless constructor. The contract is checked at the call site, not at runtime.

Constraints are the "permissions" you grant yourself inside the generic body. No constraint, no permissions. Add class, you get permission to compare against null. Add IComparable<T>, you get permission to call CompareTo. Add new(), you get permission to call new T(). Each constraint hands you a specific capability.

The diagram shows constraints as branches off the unconstrained baseline. You can stack them; we'll get to combinations later in the lesson. For now, the takeaway is that every constraint is a small unlock.

The class Constraint

where T : class says T must be a reference type. Classes, interfaces, delegates, arrays, and string all qualify. Structs, enums, and primitive value types do not.

The constraint unlocks three things. First, you can assign null to a T variable. Without the constraint, T = null doesn't compile, because T might be int, which can't be null. Second, you can compare against null with is null or == null. Third, the type works naturally with nullable reference type annotations (T? means "nullable T" when T is a reference type).

Trying to plug a value type into a class-constrained generic is rejected at the call site:

The compiler text is worth memorizing because it shows up in real code:

When nullable reference types are enabled (the default in new .NET projects), the difference between where T : class and where T : class? matters. The first requires T to be a non-nullable reference type, so Cache<string?> produces a warning. The second allows nullable reference types as well. Most repositories use the strict version, because they're storing entities, not "maybe entities."

The struct Constraint

where T : struct is the mirror of class. It restricts T to non-nullable value types: int, decimal, DateTime, Guid, enums, custom structs. It excludes reference types and Nullable<T> itself.

The constraint unlocks one important thing: the field _current doesn't need to be nullable, because value types always have a default value. It also lets you write Nullable<T> (which itself requires where T : struct) inside the generic body.

A struct-constrained generic avoids boxing when the value type is stored or passed around inside the generic. If you wrote the same code over object, every assignment would box the value onto the heap. For hot-path code (counters, IDs, money), the struct constraint can matter.

A subtle point: where T : struct excludes Nullable<T>. If you write Counter<int?>, the compiler rejects it, because int? is Nullable<int> which is itself a struct but is specifically excluded from this constraint. The rule exists because mixing nullable-of-nullable would be a mess.

The new() Constraint

where T : new() says T must have a public parameterless constructor. The unlock is new T() inside the generic body.

The factory needs to create new Order instances, and new T() is how it does that. Without the constraint, new T() produces CS0304:

The constraint is satisfied by any type with a public parameterless constructor. That includes:

TypeSatisfies new()?Reason
Order (no constructors declared)YesThe compiler-generated default constructor counts.
Cart() (explicit parameterless)YesThe explicit one counts.
Money(decimal amount) (only this)NoNo parameterless constructor exists.
abstract class Entity()NoAbstract types can't be instantiated.
int, decimal, DateTime (value types)YesEvery value type has an implicit parameterless default.

Historically, new T() under the new() constraint was lowered to Activator.CreateInstance<T>(), which uses reflection and is slow. Modern .NET runtimes recognize the pattern and emit direct IL allocation when possible, so the cost on .NET 8+ is comparable to a plain new Order(). The old advice to "avoid new T() in hot loops" is mostly outdated, but profile if you're allocating millions per second.

Base Class Constraints

where T : SomeBaseClass says T must be SomeBaseClass or a type that derives from it. The unlock is full access to the public and protected members of the base class.

Inside Record, the code can call item.CreatedAt, item.Id, and item.Describe() because the constraint guarantees T is at least an Entity. Without the constraint, none of those would be visible.

There are three rules to remember about base class constraints:

  1. The base type must be a class. You can't use where T : SomeStruct, because structs can't be inherited.
  2. The base class can't be sealed. A sealed class has no derived types, so the constraint would be useless.
  3. You can use a constructed generic type as the base, like where T : Repository<Order>. We'll see this in the type-parameter-on-type-parameter section.

One more subtlety: you can pass instances of T to methods that accept the base type. Inside AuditLog<T>, T is implicitly convertible to Entity, so you could call SomeMethodThatTakesEntity(item) and it would just work.

Interface Constraints

where T : ISomeInterface says T must implement that interface. The unlock is the interface's methods and properties.

The repository uses item.Id because IEntity exposes that property. Plug in any type that implements IEntity, and the repository works. Plug in something that doesn't, and the constraint is violated:

You can stack multiple interface constraints by listing them. We'll see the combined form in the multiple-constraints section.

A generic interface as a constraint is just as common, especially IComparable<T> and IEquatable<T>. Here's a classic:

The IComparable<T> constraint unlocks CompareTo. decimal, string, and DateTime all implement IComparable<T> against themselves, so the method works for all three.

The notnull Constraint

where T : notnull (added in C# 8 alongside nullable reference types) says T must be a non-nullable type. It accepts both reference types and value types, as long as the reference type isn't annotated as nullable.

This is exactly the constraint Dictionary<TKey, TValue> itself uses in modern .NET. Dictionary keys can't be null, and notnull is how the BCL enforces that. The unlock is small: you can use the type without nullable annotations and without compiler warnings about null dereferences.

The constraint is enforced as a warning, not an error, when nullable reference types are enabled. Plugging string? into a notnull-constrained generic produces a CS8714:

Compared to where T : class, notnull is broader because it accepts value types too. Compared to where T : struct, it's broader because it accepts reference types. Use it when the only thing you care about is "don't give me a nullable type."

The unmanaged Constraint

where T : unmanaged says T must be an unmanaged type: a value type whose fields are also unmanaged, with no reference types anywhere in the structure. Examples include int, double, Guid, DateTime, and structs of those.

The constraint exists mainly for low-level interop and performance code: you can use sizeof(T), stackalloc T[count], or reinterpret memory safely because the type has no GC-managed references. For everyday e-commerce work, you'll rarely write unmanaged constraints yourself, but you might consume APIs that use it (like Span<T> extension methods or MemoryMarshal).

A practical example without unsafe:

ProductCode qualifies because both its fields are int. If you added a string Name field, the constraint would fail at every call site, because string is a reference type and pulls the struct out of the unmanaged category.

Type Parameter Constraints (where T : U)

You can constrain one type parameter to be assignable from another. This shows up most often in repositories and processors that hold a base type but also accept derived ones.

The constraint where TItem : TBase says "whatever TItem is, it must derive from (or equal) TBase." Inside the processor, you can treat any TItem as a TBase and use it where TBase is expected. Combined with where TBase : Entity, you get a small hierarchy of guarantees: TItem is at least TBase, which is at least Entity.

This pattern is less common in day-to-day code than the other constraints, but it shows up in BCL signatures like OfType<TResult> and in fluent builders that need to relate two type parameters.

Multiple Constraints on One Type Parameter

Most real-world generics need more than one constraint. The pattern that comes up over and over in repositories is where T : class, IEntity, new():

  • class: T is a reference type, so we can compare against null and use nullable annotations.
  • IEntity: T exposes the members we need (Id, Equals, whatever).
  • new(): We can call new T() inside factory methods.

This is the standard generic constraint pattern in .NET. Entity Framework's DbSet<TEntity>, ASP.NET Core's IServiceCollection.AddScoped<T>, and dozens of internal BCL types use variations of it.

Constraint Order Rules

Multiple constraints must appear in a specific order. The compiler enforces it.

The order is:

  1. Primary constraint first. At most one of class, struct, unmanaged, or notnull. They're mutually exclusive (you can't be both a struct and a class).
  2. Base class next. At most one base class constraint. It must come before interfaces and new().
  3. Interfaces in the middle. You can list as many as you need.
  4. `new()` last. Always at the end if present.

Valid combinations:

Invalid combinations:

The CS0401 error message reads:

Multiple where Clauses for Different Type Parameters

When a type has more than one type parameter, each parameter gets its own where clause. They stack, one per line, before the opening brace.

Each where clause is independent. TEntity gets one set of constraints, TKey gets another. The two clauses don't interact unless you write where TKey : TEntity or similar to connect them.

Constraints on Classes vs Methods

Constraints can live on either a generic type declaration or a generic method declaration. The placement determines when they apply.

Constraints on a class flow into every method of that class; you don't repeat them. Constraints on a method only apply to that method and don't restrict other methods on the same type. The most common shape mixes both: a generic class has class-level constraints, and one or two methods on it add extra constraints for special operations.

Save lives under the class-level constraint. CreateAndSave<U> adds new() on its own type parameter U, which in turn must be a T. The compiler combines the constraints transitively, so U is at least class, IEntity, T, and has new().

INumber<T> and Generic Math (C# 11+)

C# 11 introduced static abstract members on interfaces, and the BCL added a family of "numeric" interfaces under System.Numerics. The headline one is INumber<T>, which lets a generic constraint promise that T supports arithmetic operators.

Before C# 11, this was impossible without writing one method per numeric type or using dynamic (slow and error-prone). The INumber<T> constraint promises +, -, *, /, comparison, Zero, One, and CreateChecked. It's implemented by int, long, decimal, double, float, and the unsigned variants.

The trade-off is a runtime cost. Generic math operators dispatch through the interface, and although the JIT specializes the code per T, the first call to a new T involves a small one-time cost. For tight inner loops over int[], a hand-written non-generic method is still faster. For everyday business code, INumber<T> fits.

What's Wrong With This Code?

A few common constraint mistakes come up here. Each one has a fix.

What's wrong with this code?

The compiler rejects new T() with CS0304 because T has no new() constraint. Without the constraint, the compiler doesn't know whether T has a parameterless constructor.

Fix:

What's wrong with this code?

With nullable reference types enabled (the default on .NET 6+), declaring private T _cached warns because the field is non-null by signature but isn't initialized in the constructor. The class constraint allows null, but the field type doesn't.

Fix: mark the field nullable and return a nullable type.

What's wrong with this code?

Sorted<Product> fails at the call site with CS0311, because Product doesn't implement IComparable<Product>. The constraint promise is broken at instantiation, not inside Sorted.

Fix: either implement IComparable<Product> on Product, or relax the constraint and accept an IComparer<T> parameter at construction.

The second form is more flexible: you can pass Comparer<Product>.Create((a, b) => a.Price.CompareTo(b.Price)) without modifying Product.

What's wrong with this code?

Stash<string> violates the struct constraint. The error appears at the call site:

Fix: if you actually want to allow reference types too, use where T : notnull or drop the constraint entirely. If you really do need a value type, fix the call site to pass int, decimal, DateTime, etc.

What Each Constraint Unlocks

A reference table. When you're staring at a generic method that almost compiles, this is the table to scan.

ConstraintWhat T can beWhat you unlock inside the body
(none)any typemembers of object only (ToString, Equals, GetHashCode, GetType)
where T : classreference typeassign null, compare with is null, nullable annotations (T?)
where T : structnon-nullable value typeno null assignment, no boxing, Nullable<T> is usable, default value is well-defined
where T : new()type with public parameterless ctornew T()
where T : notnullany non-nullable typenullable annotations enforced, no null reference warnings on T itself
where T : unmanagedunmanaged value typesizeof(T), stackalloc T[n], pointer interop
where T : SomeBaseClassSomeBaseClass or derivedpublic and protected members of SomeBaseClass
where T : ISomeInterfacetype implementing the interfacemembers of the interface
where T : T2something assignable to T2implicit conversion from T to T2
where T : default(only in disambiguation)rare; see below

The default constraint exists for a narrow case: when a generic method overrides another and the type parameter could be either a reference or value type, the compiler sometimes can't tell whether T? means "nullable reference" or "nullable value." where T : default disambiguates. You'll see it almost never. The _default Keyword in Generics_ lesson covers default(T) in depth, including this case.

Constraints Don't Propagate Through Inheritance

A common surprise: if you derive a generic class from another generic class, the constraint doesn't transfer automatically.

If you remove the where clause from AuditedRepository, the compiler complains that T doesn't satisfy Repository<T>'s constraint. Each generic declaration carries its own constraints, and the derived type must restate (or tighten) them.

You can tighten the constraints on the derived type, but you can't loosen them:

The tighter version is allowed because every IAuditable & IEntity is also an IEntity. The looser version is rejected because not every class is an IEntity.

Performance Notes

Constraints affect performance in two main ways. Neither should drive your design unless you're profiling a real bottleneck.

The first is boxing. When you write a generic over where T : struct, value types are stored in their native representation. No boxing, no heap allocations. When you write a generic over where T : class or no constraint, value types passed in still get boxed at the call site if the JIT can't specialize. In practice, .NET's JIT specializes generic code per value type, so List<int> stores raw ints without boxing even though List<T> has no struct constraint.

The second is interface dispatch. Calling a method through where T : IComparable<T> is a virtual call through the interface table. For value types, the JIT can sometimes devirtualize this when it knows T concretely (Largest<int>(a, b)), turning it into a direct call. For reference types, devirtualization is harder, and the call usually goes through the interface table at runtime. The cost is small (a few nanoseconds per call) but adds up in tight loops.

A generic method constrained by IComparable<T> calls CompareTo through interface dispatch. For int, the JIT often inlines this completely. For a custom class implementing IComparable<Product>, the call is virtual. If you're sorting millions of items, profile both shapes and consider implementing IComparer<T> explicitly to avoid the dispatch.

The new() constraint deserves one more note. Calling new T() historically went through Activator.CreateInstance<T>(), which uses reflection. On older runtimes this was 100x slower than a direct new. Since .NET 7, the JIT detects the new() constraint pattern and emits direct allocation IL. On .NET 8+, new T() with the new() constraint is comparable in cost to a hand-written new SomeType().

Quiz

Generic Constraints Quiz

10 quizzes