Every where clause is a promise the compiler enforces on your behalf — pick the promise that matches exactly what your code needs, and no more.
The previous lesson gave you the overview: constraints trade "works for absolutely anything" for real capability. Now it's time for the details — the exact syntax for each kind of where constraint, what each one specifically guarantees, how to combine several at once, and a subtler case: constraining one type parameter using another.
In this lesson, you'll go deep on where T : class, where T : struct, where T : new(), where T : BaseClass, where T : IInterface, combining multiple constraints on the same type parameter, and constraining a type parameter against another type parameter.
A constraint clause is written as where TParam : constraint1, constraint2, ... immediately after a generic type's or method's parameter list, before the opening { (or before the method body, if there's no other clause after it). Let's walk through each kind precisely.
| Constraint | Meaning | Unlocks |
|---|---|---|
where T : class | T must be a reference type | Comparing to null with reference semantics; T? means "may be null" |
where T : struct | T must be a non-nullable value type | Guaranteed never null; no boxing surprises; can combine with default(T) safely |
where T : notnull | T cannot be a nullable reference or nullable value type | A lighter guarantee than class/struct — common for dictionary-style keys |
where T : new() | T must have an accessible public parameterless constructor | new T() inside your code |
where T : BaseClass | T must be, or derive from, BaseClass | Calling any public/protected member of BaseClass on a T value |
where T : IInterface | T must implement IInterface | Calling any member IInterface declares |
Different generic code needs different guarantees. A cache that must construct a fresh default instance when a key is missing needs new(). A comparer needs an interface guarantee. A type that must never accidentally hold null needs struct. One general "constraint" concept wouldn't be precise enough — real code needs several distinct, composable guarantees, each unlocking a different specific capability.
C# provides a small, purpose-built vocabulary of constraint kinds, and lets you combine them precisely for exactly what your code needs — no more, no less. The rest of this lesson walks through each one with a concrete example of the problem it solves.
where T : class, IComparable<T>, new()
↑ ↑ ↑
1. class/struct/notnull 2. base class, then 3. new() —
(if present, comes interfaces always last
first)
class/struct/notnull) first, if present — then a base class, if any — then any number of interfaces — then new(), always last. You cannot combine class and struct together (a type can't be both).
public bool IsNull<T>(T value) where T : class
=> value is null;
IsNull<string>(null); // string is a reference type
// IsNull<int>(0); // compile error — int is a value type, not allowed
T is a reference type — arrays, classes, interfaces, and delegates all qualify. Value types (int, bool, any struct) are rejected.public T? ToNullable<T>(T value) where T : struct
=> value; // implicit conversion to T? (Nullable<T>) is always safe here
int? maybeNumber = ToNullable(42);
// ToNullable<string>("hi"); // compile error — string is a reference type
T is a value type that is itself never nullable (so int? can't be used as the type argument either — only genuinely non-nullable value types qualify).public T CreateDefault<T>() where T : new()
=> new T(); // compiles — guaranteed a parameterless constructor exists
public class Product
{
public Product() { } // has a public parameterless constructor — qualifies
}
Product p = CreateDefault<Product>();
new(), when combined with other constraints, must always come last in the list.public abstract class Shape
{
public abstract double Area();
}
public double TotalArea<T>(IEnumerable<T> shapes) where T : Shape
=> shapes.Sum(shape => shape.Area()); // Area() is guaranteed by the Shape constraint
Shape and types deriving from it qualify — unlocking every public/protected member Shape declares.public T Max<T>(T a, T b) where T : IComparable<T>
=> a.CompareTo(b) > 0 ? a : b;
public void Register<TBase, TDerived>(List<TBase> list, TDerived item)
where TDerived : TBase
{
list.Add(item); // TDerived is guaranteed assignable to TBase
}
List<Shape> shapes = [];
Register<Shape, Circle>(shapes, new Circle()); // Circle : Shape
TDerived is required to be TBase or a type derived from it — this is a less common but genuinely useful pattern when two type parameters need to stay related to each other.// Combining a type category, an interface, and the constructor constraint
public class ObjectPool<T> where T : class, IResettable, new()
{
private readonly Stack<T> _available = new();
public T Rent() => _available.Count > 0 ? _available.Pop() : new T();
public void Return(T item)
{
item.Reset(); // IResettable guarantees Reset()
_available.Push(item); // class guarantees reference semantics, safe to pool
}
}
public interface IResettable
{
void Reset();
}
public class Buffer : IResettable
{
public byte[] Data { get; } = new byte[1024];
public void Reset() => Array.Clear(Data);
}
var pool = new ObjectPool<Buffer>();
Buffer buffer = pool.Rent(); // new T() the first time — pool starts empty
// ... use buffer ...
pool.Return(buffer); // Reset() called, then pooled for reuse
Buffer reused = pool.Rent(); // pulled from the pool this time, not newly constructedCode → Meaning → Result:
class ensures pooled items behave as shared, mutable references — pooling a value type wouldn't make sense.IResettable guarantees every pooled type can be cleaned up before reuse.new() lets the pool manufacture a fresh instance the first time it's needed, with no items yet available.A Cache<TKey, TValue> that lazily creates a default value the first time a key is requested is a realistic use of combining notnull (a genuinely common real-world requirement for dictionary-style keys) with new() for the value type.
public class Cache<TKey, TValue>
where TKey : notnull
where TValue : new()
{
private readonly Dictionary<TKey, TValue> _entries = [];
public TValue GetOrCreate(TKey key)
{
if (!_entries.TryGetValue(key, out TValue? value))
{
value = new TValue();
_entries[key] = value;
}
return value;
}
}
public class UserPreferences
{
public bool DarkMode { get; set; }
public string Language { get; set; } = "en";
}
var cache = new Cache<string, UserPreferences>();
UserPreferences prefs = cache.GetOrCreate("user-42"); // constructs a fresh default the first time
prefs.DarkMode = true;
UserPreferences samePrefs = cache.GetOrCreate("user-42"); // returns the same instance now
Console.WriteLine(samePrefs.DarkMode); // TrueNotice this lesson's constraint syntax uses one where clause per type parameter, on its own — a common, very readable style once a generic type has multiple type parameters each carrying its own constraint.
Think of each constraint as one line on a rental application's eligibility checklist: "must have valid ID" (notnull), "must have a co-signer" (an interface), "must be able to move in immediately" (new()). Each requirement independently narrows the pool of eligible applicants, and you can stack as many as the situation genuinely calls for — but a landlord who demands ten unrelated requirements for a studio apartment is turning away tenants who would have been perfectly fine. The same restraint applies to constraints: add exactly what your code needs, not more.
new() constraint in place, the compiler can instead emit a direct constructor call in the generated code for reference types, avoiding that overhead — one more reason to prefer a proper constraint over an unconstrained, reflection-based workaround.where T : IComparable<T>, the compiler checks — for every closing type argument — whether that type's declared interface list includes IComparable<T>. This check happens once per closed generic type, at compile time, not repeatedly at runtime.where T : struct constraint guarantees every instantiation goes through that specialized, non-boxing path — one reason value-type-only generic code (like Nullable<T> itself) uses this constraint.class as a constraint vs class as a declaration keywordwhere T : class looks like it might mean "T must literally be the type named class" — it doesn't. In this position, class is a special keyword meaning "any reference type," not a reference to a specific class. This dual meaning (declaration keyword vs constraint keyword) trips up many learners at first.
notnull is weaker than it soundswhere T : notnull only prevents the type argument itself from being a nullable reference type (string?) or Nullable<T> (int?) — it's a compile-time nullability check tied to nullable reference types, not a runtime guarantee that a given value can never be null. It's commonly used for dictionary key type parameters, mirroring Dictionary<TKey, TValue>'s own constraint.
class and struct on the same type parameterThese two constraints are mutually exclusive by definition — a type cannot be both a reference type and a value type — so where T : class, struct is simply not legal syntax.
new() anywhere but lastWrong — doesn't compile:
where T : new(), IComparable<T> // new() must come lastCorrect:
where T : IComparable<T>, new() // new() is lastwhere T : struct to allow int? MyMethod<int?>(...) against a where T : struct constraint fails to compile — int? (Nullable<int>) is technically a value type, but the struct constraint specifically excludes nullable value types. If you need to allow both int and int?, don't use struct — reconsider whether the constraint is really needed, or accept T? explicitly where relevant.
where T : Shape when all you actually need is an Area() method — this forces every future type to inherit from Shape specifically, even if it could have satisfied the need some other way. Prefer an interface constraint (where T : IHasArea) whenever you only need specific behavior, not a specific inheritance lineage — it keeps your generic code usable by a wider range of types.
class — you rely on reference semantics, or need to safely check for null.struct — you rely on value semantics, or want to avoid nullable surprises.notnull — a lighter nullability guarantee is enough, e.g. for a dictionary-style key.new() — your code needs to construct fresh instances of T itself.object's.class = reference type only · struct = non-nullable value type onlynotnull = no nullable reference or nullable value type · new() = has a public parameterless constructorBaseClass/IInterface = must be, derive from, or implement itnew() (always last).where TDerived : TBase).class and struct constrain T to reference types or non-nullable value types, respectively — mutually exclusive, cannot be combined.notnull is a lighter nullability guarantee, commonly used for dictionary-style keys.new() guarantees a public parameterless constructor, unlocking new T() — and must always be listed last when combined with others.where TDerived : TBase) when two generic parameters need to stay related.You've gone deep on every kind of where constraint. Let's check your understanding.
1. Which of the following is a valid, correctly-ordered combination of constraints?
Correct: B
Why B is correct: This follows the required order: type category (class) first, then interfaces, then new() last.
Why A is incorrect: new() must always be the last constraint listed, never before class.
Why C is incorrect: class and struct are mutually exclusive — a type cannot be both a reference type and a value type.
Why D is incorrect: The type category constraint (class) must come before any interface constraints, not after.
Reinforcement: Type category first, then base class, then interfaces, then new() — that fixed order is required by the compiler.
2. Why does where T : struct reject int? (Nullable<int>) as a type argument, even though Nullable<int> is technically a value type?
Correct: B
Why B is correct: The struct constraint is defined to mean "non-nullable value type" — Nullable<T> is deliberately carved out, even though it's implemented as a value type, because it defeats the "guaranteed never null" guarantee the constraint is meant to provide.
Why A is incorrect: int? is a perfectly real, commonly used C# type — it's excluded specifically from the struct constraint, not invalid as a type generally.
Why C is incorrect: struct accepts any non-nullable value type, including custom structs, not just int and not string (which is a reference type).
Why D is incorrect: This is standard, documented, intentional C# behavior — not a bug.
Reinforcement: struct means "guaranteed non-nullable value type" — a deliberate, narrower guarantee than "any value type."
3. What does where TDerived : TBase mean in public void Register<TBase, TDerived>(List<TBase> list, TDerived item) where TDerived : TBase?
Correct: B
Why B is correct: As shown in "How It Works," a type parameter can be constrained against another type parameter — this guarantees whatever is supplied as TDerived is compatible with (assignable to) whatever is supplied as TBase, which is exactly what lets list.Add(item) compile safely.
Why A is incorrect: This is valid, well-supported C# syntax — constraining one type parameter by another is a real, if less common, pattern.
Why C is incorrect: They don't need to match exactly — TDerived just needs to be TBase or something derived from/implementing it, e.g. TBase = Shape, TDerived = Circle.
Why D is incorrect: This reverses the relationship — the constraint reads TDerived : TBase, meaning TDerived is the more specific one, not the other way around.
Reinforcement: Constraining one type parameter against another keeps two independently-supplied types provably compatible with each other.
4. A generic Cache<TKey, TValue> needs to construct a brand-new TValue whenever a key is missing, and needs TKey to be safely usable as a dictionary key. Which constraints fit best?
Correct: B
Why B is correct: This exactly matches the Cache<TKey, TValue> real-world example — notnull on the key type (mirroring Dictionary<TKey,TValue>'s own requirement), and new() on the value type so a fresh default can be constructed on demand.
Why A is incorrect: This swaps the constraints backwards — the key needs the nullability guarantee, and the value needs the constructor guarantee, not the other way around.
Why C is incorrect: Without new(), new TValue() wouldn't compile at all — some constraint is required here.
Why D is incorrect: struct would needlessly exclude perfectly valid reference-type keys and values (like string keys or a class-based value), and doesn't provide either of the two specific guarantees actually needed here.
Reinforcement: Match each constraint to the exact capability your code relies on — here, "safe dictionary key" and "constructible on demand" are two separate, distinct needs.
You now know every kind of generic constraint, and how to combine them precisely. Next: covariance and contravariance — how out and in let generic interfaces flex safely along inheritance hierarchies.
dotnetmadeeasy.com — Learn C# and .NET, the right way.