C# where Constraints: class, struct, new() and notnull
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.
What Is It?
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 |
Why Does It Exist?
The Problem
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.
The Solution
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.
Big Picture
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).
How It Works
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
- Guarantees
Tis a reference type — arrays, classes, interfaces, and delegates all qualify. Value types (int,bool, anystruct) 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
- Guarantees
Tis a value type that is itself never nullable (soint?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
- A class constraint means only
Shapeand types deriving from it qualify — unlocking every public/protected memberShapedeclares.
public T Max<T>(T a, T b) where T : IComparable<T>
=> a.CompareTo(b) > 0 ? a : b;
- Interface constraints are the most common kind in real code — they unlock exactly the members that interface declares, without forcing a specific inheritance hierarchy the way a base class constraint would.
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
- A type parameter can be constrained by another type parameter, not just a concrete type. Here,
TDerivedis required to beTBaseor 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.
Simple Example
// 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:
classensures pooled items behave as shared, mutable references — pooling a value type wouldn't make sense.IResettableguarantees 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.
Real-World Example
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.
Analogy
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.
Under the Hood
- Historically, unconstrained construction relied on reflection-based helpers, which carry real runtime overhead. With the
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.
- When you write
where T : IComparable<T>, the compiler checks — for every closing type argument — whether that type's declared interface list includesIComparable<T>. This check happens once per closed generic type, at compile time, not repeatedly at runtime.
- As covered in the "Generics" lesson, value-type type arguments get their own specialized JIT-compiled version, while reference types typically share one. A
where T : structconstraint guarantees every instantiation goes through that specialized, non-boxing path — one reason value-type-only generic code (likeNullable<T>itself) uses this constraint.
Common Confusion
1. class as a constraint vs class as a declaration keyword
where 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.
2. notnull is weaker than it sounds
where 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.
3. You cannot combine class and struct on the same type parameter
These 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.
Common Mistakes
Mistake 1 — Putting new() anywhere but last
Wrong — doesn't compile:
where T : new(), IComparable<T> // new() must come lastCorrect:
where T : IComparable<T>, new() // new() is lastMistake 2 — Expecting where 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.
Mistake 3 — Reaching for a base class constraint when an interface would be more flexible
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.
When Should I Use It?
Use each constraint when
class— you rely on reference semantics, or need to safely check fornull.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 ofTitself.- Base class / interface — your code needs to call specific members beyond
object's.
Reconsider when
- You're constraining to a base class purely to reach one method — an interface is usually a better fit.
- You've stacked several constraints but only actually use one or two of them inside the code — trim to what's genuinely needed.
Mental Model
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 itRemember:
· Order: type category → base class → interfaces →
new() (always last).· You can constrain a type parameter against another type parameter (
where TDerived : TBase).· Prefer interfaces over base classes when you only need specific behavior, not a specific hierarchy.
Key Takeaway
classandstructconstrainTto reference types or non-nullable value types, respectively — mutually exclusive, cannot be combined.notnullis a lighter nullability guarantee, commonly used for dictionary-style keys.new()guarantees a public parameterless constructor, unlockingnew T()— and must always be listed last when combined with others.- A base class or interface constraint unlocks that type's members — prefer interfaces when you only need specific behavior, not a fixed hierarchy.
- A type parameter can even be constrained against another type parameter (
where TDerived : TBase) when two generic parameters need to stay related.
Check Your Understanding
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?
Show answer
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?
Show answer
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?
Show answer
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?
Show answer
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.