Lesson • Advanced Track
Generics: Constraints, Variance & Advanced Use Cases
By the end of this lesson you'll be able to write your own generic classes and methods, lock down what types they accept with constraints, bend the rules safely with covariance and contravariance, and understand exactly why generics beat the old object-and-cast approach on both safety and speed.
What You'll Learn
- Write generic methods and classes with one or more type parameters
- Apply constraints: where T : class / struct / new() / IComparable<T>
- Use covariance (out T) to substitute a derived type for a base type
- Use contravariance (in T) to substitute a base type for a derived type
- Use default(T) to get the safe ‘zero’ value of any type parameter
- Explain why generics beat object + casting (type safety, no boxing)
List<int>) and OOP (classes, interfaces, inheritance). This lesson builds directly on all three.💡 Real-World Analogy
Think of a generic class like a vending machine with a swappable product slot. The machine's mechanism — take money, dispense, give change — is built once and never changes; the slot is labelled T and you decide what it holds when you install the machine (crisps, drinks, or stationery). A constraint is the slot's size limit: "only items that fit a standard tray" (where T : IComparable<T> means "only things that can be compared"). Covariance is a machine that only dispenses — a "dog dispenser" can safely be relabelled an "animal dispenser", because every dog it hands out is an animal. Contravariance is a machine that only accepts — a recycling bin that takes any waste can certainly take paper.
Why Generics Exist
Before generics (C# 1.0), reusable containers stored everything as object — the base type of every type. That worked, but it had two costs. First, no type safety: a List of object would happily hold an int next to a string, and you only found out you'd guessed wrong when a cast threw an exception at runtime. Second, boxing: every value type (like int) had to be wrapped in a heap object to be stored as object, then unwrapped on the way out — slow, and it churns memory.
Generics fix both. A type parameter — written T by convention — is a placeholder you fill in when you use the type. List<int> can only ever hold ints, the compiler enforces it, and the ints are stored directly with no boxing. You write the logic once and reuse it for any type, with full type safety and zero casting.
📊 Constraint & Variance Quick Reference
| Keyword | Meaning | Unlocks / Example |
|---|---|---|
| where T : struct | Value type only | int, double, DateTime |
| where T : class | Reference type only | T may be null |
| where T : new() | Has a parameterless constructor | Lets you call new T() |
| where T : IComparable<T> | Implements an interface | Lets you call CompareTo() |
| where T : BaseClass | Inherits from a class | Guarantees the base members |
| out T | Covariant (returns only) | IEnumerable<out T> |
| in T | Contravariant (accepts only) | Action<in T> |
| default(T) | The 'zero' value of T | 0 / false / null |
Constraint order is fixed: class or struct first, then any interfaces or base class, then new() last.
1. Why Generics Beat object + Casting
Start by seeing the problem generics solve. Storing values as object compiles, but it hands you two booby traps: bad casts that only blow up at runtime, and boxing — wrapping value types in heap objects, which is slow. A List<int> sidesteps both: the compiler refuses anything that isn't an int, and the ints are stored directly. Read this worked example and run it.
Worked example: object + cast vs a real generic
See where the old object approach fails and how List<int> stays safe and fast.
using System;
using System.Collections.Generic;
class Program
{
static void Main()
{
// THE OLD WAY (before generics): store everything as 'object'.
// 'object' is the base type of EVERYTHING, so a list of object
// can hold anything — but that flexibility costs you safety.
var oldList = new List<object>();
oldList.Add(42); // an int gets BOXED into an object (heap alloc)
oldList.Add("hello"); // oops — nothing stops mixed typ
...2. Generic Methods
A generic method declares its own type parameter in angle brackets after the name: static T Max<T>(T a, T b). You can usually leave the type off when you call it — the compiler infers T from the arguments. To call methods on a T (like CompareTo), you must promise it has them with a constraint: where T : IComparable<T>. Finish the two blanks below, then run it.
🎯 Your turn: write a generic Max<T>
Add the IComparable<T> constraint and return the bigger value. Check all three lines.
using System;
class Program
{
// 🎯 YOUR TURN — finish this generic method, then run Main.
// A GENERIC METHOD has a type parameter <T> after its name.
// The constraint 'where T : IComparable<T>' guarantees T has CompareTo,
// so we can compare any two values of the same type.
static T Max<T>(T a, T b) where T : ___ // 👉 IComparable<T>
{
// CompareTo returns > 0 when 'a' is the bigger value.
return a.CompareTo(b) >= 0 ? ___ : b; // 👉 return a when
...3. Generic Classes
A generic class puts the type parameter on the class itself: class Box<T>. Inside, T behaves like a real type — you can declare fields, parameters, and return types of type T. You pick the actual type when you create an object: new Box<int>(). Read the worked Box<T> first, then finish the two-parameter Pair class that follows.
Worked example: a generic Box<T>
One class, reused for int and string — each box keeps its own typed value.
using System;
// A GENERIC CLASS — Box<T> can hold a value of ANY single type T.
// Write the class once; reuse it for int, string, Product, anything.
class Box<T>
{
private T _value; // the stored value, typed as T
public bool HasValue { get; private set; }
public void Put(T item) // 'item' must be a T
{
_value = item;
HasValue = true;
Console.WriteLine($"Put {item} into the box.");
}
public T Peek() => _value;
...Your turn. A class can take more than one type parameter — Pair<TFirst, TSecond> holds two values of possibly different types. Fill in the two ___ blanks to finish the constructor and Swap, then run it.
🎯 Your turn: finish a generic Pair<TFirst, TSecond>
Store the second value and swap the pair. The output should flip the two values.
using System;
// 🎯 YOUR TURN — finish this generic Pair class, then run Main.
// A GENERIC CLASS can take MORE than one type parameter.
// Pair<TFirst, TSecond> stores two values that can be different types.
class Pair<TFirst, TSecond>
{
public TFirst First { get; }
public TSecond Second { get; }
public Pair(TFirst first, TSecond second)
{
First = first;
Second = ___; // 👉 store the 'second' parameter in Second
}
// Swap returns a new Pair w
...4. Constraints — Restricting T
By default T could be anything, so the compiler only lets you use the members every type has (basically ToString, Equals). A constraint narrows T and unlocks more: where T : struct (value types), where T : class (reference types, so null is allowed), where T : new() (lets you write new T()), and where T : IComparable<T> (lets you call CompareTo). You can combine several — all must hold.
Worked example: struct, class, new() and interface constraints
Each constraint unlocks a different capability on T. Run it and read the comments.
using System;
class Widget { public override string ToString() => "Widget"; }
class DataTools
{
// where T : struct — VALUE types only (int, double, DateTime).
// This lets you safely use T? as a nullable value type.
static T Bigger<T>(T a, T b) where T : struct, IComparable<T>
=> a.CompareTo(b) >= 0 ? a : b;
// where T : class — REFERENCE types only. Now T can legally be null.
static void PrintOrNull<T>(T? item) where T : class
=> Console.WriteLine(item?.T
...5. Covariance (out T)
Covariance lets you use a "more derived" generic type where a "less derived" one is expected — but only when the type parameter is marked out, meaning T is only ever returned, never accepted as a parameter. Because every value coming out is at least the base type, the substitution is always safe. That's why an IProducer<Dog> can be treated as an IProducer<Animal>, and why IEnumerable<out T> in the standard library is covariant.
Worked example: out T (covariance)
A dog factory used as an animal factory — derived substitutes for base on the way out.
using System;
using System.Collections.Generic;
// COVARIANCE — 'out T' means T is only ever RETURNED, never accepted.
// That makes it safe to treat IProducer<Dog> as IProducer<Animal>:
// every Dog it hands out really IS an Animal.
interface IProducer<out T>
{
T Produce();
}
class Animal { public virtual string Sound => "..."; }
class Dog : Animal { public override string Sound => "Woof!"; }
class DogFactory : IProducer<Dog>
{
public Dog Produce() => new Dog(); // always makes a D
...6. Contravariance (in T)
Contravariance is the mirror image. Mark the type parameter in and T is only ever accepted as a parameter, never returned. Now a "less derived" consumer can stand in for a "more derived" one: something that can handle any Animal can certainly handle a Dog. That's why an IConsumer<Animal> can be used as an IConsumer<Dog>, and why Action<in T> is contravariant.
Worked example: in T (contravariance)
An animal shelter used as a dog shelter — base substitutes for derived on the way in.
using System;
// CONTRAVARIANCE — 'in T' means T is only ever ACCEPTED, never returned.
// That makes it safe to treat IConsumer<Animal> as IConsumer<Dog>:
// something that can handle ANY animal can certainly handle a dog.
interface IConsumer<in T>
{
void Consume(T item);
}
class Animal { public virtual string Name => "animal"; }
class Dog : Animal { public override string Name => "dog"; }
class Shelter : IConsumer<Animal>
{
public void Consume(Animal item)
=> Console.WriteLi
...🔎 Deep Dive: the out/in mnemonic
The keywords describe the only direction the data can flow. out means T appears only in output positions (return types) → covariant → you can swap in a derived type. in means T appears only in input positions (parameters) → contravariant → you can swap in a base type.
Two rules that catch everyone out:
- • Variance only works on interfaces and delegates, never on classes. You can't make
List<Dog>covariant — use the covariantIEnumerable<Dog>instead. - • It only applies to reference-type arguments.
IEnumerable<int>is not anIEnumerable<object>, because that would require boxing.
interface IProducer<out T> { T Produce(); } // out -> covariant
interface IConsumer<in T> { void Consume(T x); } // in -> contravariant
IProducer<Animal> p = new DogProducer(); // ✅ Dog producer AS Animal producer
IConsumer<Dog> c = new AnimalConsumer(); // ✅ Animal consumer AS Dog consumer7. default(T) — a Safe Fallback
Sometimes a generic method needs to return "nothing sensible" — but you can't write return 0 (what if T is a string?) or return null (what if T is an int?). default(T) solves this: it's the natural zero value for whatever T turns out to be — 0 for numbers, false for bool, and null for reference types and string. In modern C# you can shorten it to just default when the type is obvious.
Worked example: default(T)
One method returns the right 'empty' value for ints and strings alike.
using System;
class Program
{
// default(T) gives you the 'zero' value for ANY type without knowing it:
// numbers -> 0, bool -> false, reference types & string -> null.
static T FirstOrDefault<T>(T[] items)
{
if (items.Length == 0)
return default(T); // safe fallback when there's nothing to return
return items[0];
}
static void Main()
{
int[] empty = { };
string[] words = { "alpha", "beta" };
// T = int -
...Putting It Together: a Generic Repository
Here's a small but real program that combines the lesson — a generic interface, a generic class implementing it, a where T : class constraint, and default as a safe fallback. The Repository pattern wraps storage behind Add/GetById; written generically, one class serves every entity type in an app.
Worked example: a generic IRepository<T>
One repository class works for any reference type — add products and look them up by id.
using System;
using System.Collections.Generic;
// === A generic Repository — uses everything from this lesson at once ===
// where T : class, new() -> T is a reference type we can also 'new' up.
interface IRepository<T> where T : class
{
void Add(T entity);
T? GetById(int id);
int Count { get; }
}
class InMemoryRepository<T> : IRepository<T> where T : class
{
private readonly List<T> _items = new(); // one List<T> backs every entity type
public void Add(T entity)
...Notice GetById returns default (which is null for a class) when the id is out of range — no special "not found" type needed, because default(T) already gives the right empty value.
Pro Tips
- 💡 Prefer interface constraints over base-class constraints:
where T : IComparable<T>accepts far more types thanwhere T : MyBaseClass. - 💡 Remember the mnemonic:
out= output (return) = covariant;in= input (parameter) = contravariant. - 💡 Variance is interfaces and delegates only: you can't make
List<Dog>covariant — exposeIEnumerable<Dog>instead. - 💡 Constrain only what you use: every extra constraint shrinks the set of types your generic accepts. Add a constraint only when the body actually needs it.
- 💡 Name multiple parameters meaningfully:
Tis fine for one, but preferTKey/TValueorTFirst/TSecondwhen there are several.
Common Errors (and the fix)
- "CS0314: The type 'X' cannot be used as type parameter 'T'... There is no boxing conversion or type parameter conversion": you passed a type that doesn't satisfy the constraint — e.g. a type that isn't
IComparable<T>toMax<T>. Either make the type implement the interface or relax the constraint. - "CS0452: The type 'int' must be a reference type in order to use it as parameter 'T'": you used a value type where
where T : classwas required. Pass a reference type, or change the constraint tostruct. - "CS0453: The type 'string' must be a non-nullable value type in order to use it as parameter 'T'": the mirror image — you used a reference type where
where T : structwas required. Pass a value type instead. - "CS0304: Cannot create an instance of the variable type 'T' because it does not have the new() constraint": you wrote
new T()withoutwhere T : new(). Add thenew()constraint so the compiler can guarantee a parameterless constructor. - Accidental boxing with
object: storing anintin aList<object>silently boxes it onto the heap and forces a cast back out — slow and unsafe. UseList<int>so the value is stored directly with no cast. - "CS1961: Invalid variance: the type parameter 'T' must be invariantly valid": you marked a parameter
outbut usedTas a method input (orinbut returnedT). Anout Tmay only appear in return positions; anin Tonly in parameter positions.
📋 Quick Reference
| Task | Code | Notes |
|---|---|---|
| Generic method | static T Max<T>(T a, T b) | T inferred from args |
| Generic class | class Box<T> { ... } | Pick T at new |
| Two parameters | class Pair<TKey, TValue> | Names them clearly |
| Interface constraint | where T : IComparable<T> | Unlocks CompareTo |
| new() constraint | where T : new() | Allows new T() |
| Covariance | interface IOut<out T> | Derived → base (return) |
| Contravariance | interface IIn<in T> | Base → derived (accept) |
| Safe fallback | return default(T); | 0 / false / null |
Frequently Asked Questions
Q: When should I add a constraint versus leaving T open?
Add a constraint only when the method body needs a capability — calling CompareTo needs IComparable<T>, writing new T() needs new(). Every constraint you add shrinks the set of types you can use, so stay as loose as the code allows.
Q: What's the real difference between out T and in T?
out T (covariant) means T only comes out as a return value, so a derived type can substitute for a base type. in T (contravariant) means T only goes in as a parameter, so a base type can substitute for a derived type. They are opposites.
Q: Why can't I make my own List<T> covariant?
Because a list both returns and accepts T (you can read items and Add them). If List<Dog> were a List<Animal>, you could Add a Cat to it. Variance is only allowed when T flows in one direction — which is why it's restricted to interfaces and delegates.
Q: Is default(T) always null?
No — only for reference types (and string). For value types it's the zero value: 0 for numbers, false for bool, and an all-zero struct for custom value types. That's exactly why default(T) is safer than hard-coding null or 0.
Q: Do generics make my program slower?
The opposite, usually. Generics avoid the boxing and casting that the old object approach forced on value types, so a List<int> is faster and uses less memory than a List<object> of boxed ints.
Mini-Challenge: Build a generic Stack<T>
No blanks this time — just a brief and an outline. Build a generic Stack<T> backed by a List<T>, with Push, Pop (returning default(T) when empty), and a Count property. Then push three numbers and pop two. Run it and check your output against the comments.
🎯 Mini-Challenge: a generic Stack<T>
Write the generic class yourself; the final Count should be 1 after two pops.
using System;
// 🎯 MINI-CHALLENGE: Build a generic Stack<T>
// A stack is "last in, first out" — Push adds on top, Pop removes the top.
//
// 1. Make the class generic: class Stack<T> with a private List<T> backing it
// (add using System.Collections.Generic; at the top).
// 2. Push(T item): add the item to the end of the list.
// 3. Pop(): remove AND return the last item. If empty, return default(T).
// 4. A Count property that returns how many items are stored.
//
// In Main: make a Sta
...🎉 Lesson Complete
- ✅ Generics give type safety and avoid boxing — they beat
object+ casting on both counts - ✅ Generic methods (
Max<T>) and generic classes (Box<T>) let you write logic once for any type - ✅ Constraints narrow
T:struct,class,new(),IComparable<T>, base classes - ✅ Covariance (
out T): derived substitutes for base (return-only) - ✅ Contravariance (
in T): base substitutes for derived (accept-only) - ✅ Variance applies to interfaces and delegates only, never to classes
- ✅
default(T)is the safe 'zero' value for any type parameter - ✅ Next lesson: Async Internals — how
async/awaitreally works under the hood
Sign up for free to track which lessons you've completed and get learning reminders.