← Open in the full interactive course (progress tracking, search & more)

Unconstrained T can only do what every possible type can do — which turns out to be almost nothing. Constraints tell the compiler what T is guaranteed to support.

Try writing a generic method that compares two values to find the larger one:

public T Max<T>(T a, T b) { return a > b ? a : b; // compile error: operator '>' cannot be applied to type 'T' }

This feels like it should obviously work — after all, 3 > 2 and "b" > "a" both compile just fine on their own. But the compiler rejects it. Why? Because T, with no further information, could be literally anything — a Product, a Customer, a custom struct with no comparison operator defined at all. The compiler has to guarantee this method works for every possible type someone might plug in for T, and most types simply don't support >.

In this lesson, you'll understand exactly why unconstrained T is so limited — no comparisons, no member calls beyond what object itself offers, no new T() — and get an overview of the different kinds of constraints C# provides to lift those restrictions safely.

What Is It?

The Simple Explanation

A constraint is a rule you attach to a type parameter that narrows down what T is allowed to be. Instead of "T could be absolutely anything," a constraint says something like "T must be a reference type," or "T must implement IComparable<T>," or "T must have a public parameterless constructor." In exchange for narrowing the possibilities, the compiler lets you rely on whatever the constraint guarantees, inside the method or class.

The Technical Definition

Without any constraint, an unconstrained type parameter T is treated by the compiler as if it were exactly object for the purposes of what members you can call on it — because object is the only thing guaranteed common to every possible type. A constraint clause, written as where T : ... after the type parameter list, tells the compiler that whatever type argument is eventually supplied will satisfy some additional guarantee, which the compiler then verifies at every call site — and which your code can then depend on inside the method or class body.

What Unconstrained T Can Actually Do

With no constraint, the only members you can call on a value of type T are the ones every type in .NET has, because it ultimately derives from object:

Why Does It Exist?

The Problem

Generics promise that the same code works safely for every type argument someone might ever supply — including types that don't even exist yet, written by developers who've never seen your code. If the compiler let a > b compile for an unconstrained T, it would have no way to guarantee that promise, because most types don't define what > even means for them. Allowing it would just move the failure from "won't compile" (safe, caught immediately) to "throws at runtime, or worse, silently does something wrong" (unsafe, caught late or never).

The Solution

Constraints let you trade some of that "works for every conceivable type" generality for real capability, in a way the compiler can still fully verify. By writing where T : IComparable<T>, you're telling the compiler: "I'm not claiming this works for every type anymore — only for types that implement IComparable<T>. In exchange, let me call CompareTo inside this method, and reject at compile time any type argument that doesn't qualify."

public T Max<T>(T a, T b) where T : IComparable<T> { return a.CompareTo(b) > 0 ? a : b; // compiles — IComparable<T> guarantees CompareTo exists } int larger = Max(3, 7); // int implements IComparable<int> string laterName = Max("Ana", "Ben"); // string implements IComparable<string> // Max(new object(), new object()); // compile error — object doesn't implement IComparable<object>

Notice the failure for object now happens at the call site, at compile time — exactly where you want it, not buried inside Max's implementation as a runtime surprise.

Big Picture

WIDER T ↔ MORE CAPABILITY — A TRADE-OFF
Unconstrained T
Accepts any type at all

Can only call object members
(ToString, Equals, GetHashCode)
T with a constraint
Accepts only types matching the constraint

Can call whatever the constraint guarantees
(CompareTo, a base class's members, new T()...)
Every constraint you add narrows what T can be — and widens what you're allowed to do with it.

How It Works

THE KINDS OF CONSTRAINTS, AT A GLANCE — DEEP DIVE IN THE NEXT LESSON
1. TYPE CATEGORY CONSTRAINTS
2. TYPE RELATIONSHIP CONSTRAINTS
3. CONSTRUCTOR CONSTRAINT
4. COMBINING CONSTRAINTS

Simple Example

// Unconstrained — extremely limited public void Describe<T>(T item) { Console.WriteLine(item!.ToString()); // ToString() is on object — always allowed // Console.WriteLine(item.Length); // compile error — Length isn't guaranteed for every T } // Constrained — now the compiler knows more, so more is allowed public void PrintLength<T>(T item) where T : System.Collections.ICollection { Console.WriteLine(item.Count); // ICollection guarantees a Count property } PrintLength(new List<int> { 1, 2, 3 }); // 3 — List<int> implements ICollection // PrintLength(42); // compile error — int doesn't implement ICollection

Code → Meaning → Result:

Real-World Example

Sorting a product catalog by price is a realistic scenario where a constraint is unavoidable — you can't sort without comparing, and you can't compare an unconstrained T.

public record Product(string Name, decimal Price) : IComparable<Product> { public int CompareTo(Product? other) => Price.CompareTo(other?.Price ?? 0); } public class Catalog<T> where T : IComparable<T> { private readonly List<T> _items = []; public void Add(T item) => _items.Add(item); public T? GetCheapest() => _items.Count == 0 ? default : _items.Min(); public List<T> SortedAscending() => [.. _items.OrderBy(x => x)]; } var catalog = new Catalog<Product>(); catalog.Add(new Product("Notebook", 4.75m)); catalog.Add(new Product("Pen", 2.50m)); catalog.Add(new Product("Backpack", 39.99m)); Product? cheapest = catalog.GetCheapest(); Console.WriteLine(cheapest is not null ? $"{cheapest.Name}: {cheapest.Price:C}" : "empty catalog"); // Pen: $2.50

Because Catalog<T> constrains T to IComparable<T>, the compiler both guarantees Catalog<T> can safely compare items internally, and rejects — at compile time — any attempt to build a Catalog of a type that can't be compared. The full mechanics of IComparable<T> get their own dedicated lesson later in this module.

Analogy

A Job Posting's Required Qualifications

Think of an unconstrained T as a job posting open to literally anyone, with zero requirements — the employer can only assume the applicant is a human being (the object-level baseline), nothing more specific. A constraint is like adding "must have a valid driver's license" to the posting: it narrows who can apply, but in exchange, the employer can now safely assign driving tasks, knowing every applicant who made it through actually qualifies.

where T : IComparable<T> is exactly this — "only types that can genuinely be compared need apply," and in exchange, the code inside gets to safely call CompareTo without ever worrying it might not exist.

Under the Hood

WHY THE COMPILER MUST BE THIS STRICT
1. A GENERIC DEFINITION IS COMPILED ONCE, FOR ALL FUTURE T
2. WITHOUT A CONSTRAINT, "EVERY POSSIBLE TYPE" INCLUDES THE WORST CASE
3. CONSTRAINTS ARE CHECKED AT EVERY CALL SITE, NOT JUST ONCE

Common Confusion

1. "It compiled for int, so it should work for anything" — a common but wrong assumption

Just because a > b works when you mentally substitute int for T doesn't mean the compiler will allow it for an unconstrained T — the compiler doesn't test your generic code against one type you happen to be thinking about; it must verify it against every type that could ever be substituted. This is the single most common point of confusion when learning generics.

2. A constraint restricts type arguments — it doesn't add new syntax or behavior to T itself

Writing where T : IComparable<T> doesn't make T comparable by magic — it simply refuses to compile unless the caller supplies a type argument that already implements IComparable<T> on its own. The constraint is a gate, not a feature-adder.

3. Unconstrained doesn't mean "unsafe" — it means "limited"

Unconstrained generics aren't a hole in C#'s type safety — quite the opposite. The strict limitation on what you can do with an unconstrained T is precisely what keeps generic code fully type-safe for every possible type argument. Loosening that limitation without a constraint would be the unsafe move.

Common Mistakes

Mistake 1 — Trying to compare or do arithmetic on an unconstrained T

Wrong — none of these compile:

public T Bigger<T>(T a, T b) => a > b ? a : b; // '>' undefined for T public T Add<T>(T a, T b) => a + b; // '+' undefined for T

Correct — add the constraint that actually guarantees the capability you need (the next lesson covers exactly which constraint fits which need):

public T Bigger<T>(T a, T b) where T : IComparable<T> => a.CompareTo(b) > 0 ? a : b; // compiles

Mistake 2 — Calling new T() without the constructor constraint

public T CreateDefault<T>() => new T(); — fails to compile; the compiler has no guarantee every possible T has a public parameterless constructor. Add where T : new(), covered in depth in the next lesson.

Mistake 3 — Over-constraining "just to be safe"

Piling on constraints the method doesn't actually need — this needlessly shrinks the set of types callers can use, hurting reusability for no real benefit. Add only the constraints your implementation genuinely relies on — if you're not calling a member the constraint would unlock, you probably don't need that constraint.

When Should I Use It?

Add a constraint when

Leave T unconstrained when

Rule of thumb: Let the compiler tell you when you need a constraint — write the unconstrained version first, and only add where T : ... once the compiler actually complains that a member you're calling isn't guaranteed to exist. That naturally keeps your constraints minimal and purposeful.

Mental Model

Unconstrained T = treated like object — only ToString, Equals, GetHashCode, GetType are usable
Constraint (where T : ...) = a rule narrowing what types can fill T, in exchange for more capability inside your code
The compiler's job = guarantee your generic code works for every type argument that satisfies whatever constraints you declared

Remember:
· "It works for the one type I'm thinking of" is not the same as "it works for every possible T."
· A constraint is a gate on type arguments, not a feature you're adding to T.
· Add constraints only for capabilities your code genuinely uses.

Key Takeaway


Check Your Understanding

You've seen why unconstrained generics are so limited, and why constraints exist. Let's check your understanding.

1. Why does public T Max<T>(T a, T b) => a > b ? a : b; fail to compile?

Show answer

Correct: B

Why B is correct: The compiler cannot assume every possible type argument supports > — most user-defined types don't. Since generic code is compiled once for all future type arguments, the compiler must reject anything not guaranteed for every case.

Why A is incorrect: > works fine for specific types like int — the problem is only that it isn't guaranteed for an unconstrained, unknown T.

Why C is incorrect: Generic methods return values all the time — this has nothing to do with the failure here.

Why D is incorrect: T is a perfectly valid name for a type parameter used as a return type — naming isn't the issue.

Reinforcement: "It would work for the type I'm imagining" isn't good enough for the compiler — it must hold for every type that could ever be substituted.

2. With no constraint on T, which of the following can you safely call inside a generic method?

Show answer

Correct: B

Why B is correct: ToString() is defined on object, which every type in .NET ultimately derives from — so it's always safe to call, even on an unconstrained T.

Why A is incorrect: CompareTo is not guaranteed by every type — it requires a constraint like where T : IComparable<T>.

Why C is incorrect: Length is specific to certain types (arrays, strings) — not guaranteed for an unconstrained T.

Why D is incorrect: Constructing new T() requires the new() constraint, since not every type has an accessible public parameterless constructor.

Reinforcement: Only object-level members are safe on an unconstrained T — everything else needs an explicit constraint to unlock.

3. What does adding a constraint like where T : IComparable<T> actually do?

Show answer

Correct: B

Why B is correct: A constraint is a gate, not a feature-adder — it only permits type arguments that already satisfy it, and in return, the compiler allows your code to call whatever that constraint guarantees.

Why A is incorrect: Types that don't already implement IComparable<T> are rejected as type arguments, not magically granted the capability.

Why C is incorrect: Constraints are fully enforced by the compiler at every call site — attempting to use a non-conforming type argument is a compile error, not just a comment.

Why D is incorrect: No such conversion happens — T stays whatever concrete type it was; the constraint only restricts and verifies, it doesn't transform anything.

Reinforcement: Constraints are checked, compile-time restrictions on eligible type arguments — never a way to add behavior to a type that doesn't already have it.

4. A generic method needs to call a domain-specific method, Validate(), on every value of type T it receives. What's the correct approach?

Show answer

Correct: B

Why B is correct: This is exactly the pattern demonstrated with IComparable<T> and ICollection — constraining T to an interface that declares the method you need lets the compiler both guarantee it exists and reject types that don't support it.

Why A is incorrect: Validate() isn't an object-level member — it won't compile on an unconstrained T.

Why C is incorrect: Casting to dynamic would compile, but it throws away compile-time checking entirely and defers the error to runtime — exactly the kind of unsafe workaround constraints exist to avoid.

Why D is incorrect: Custom methods are callable all the time inside generic code — as long as a constraint guarantees they exist.

Reinforcement: Constraining to an interface that declares the exact member you need is the standard, type-safe way to unlock custom behavior inside generic code.

You now understand why constraints exist and what problem they solve. Next: a deep dive into each kind of where constraint, including how to combine several at once.


dotnetmadeeasy.com — Learn C# and .NET, the right way.