Generics Advanced
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.
Part of the free C# course at LearnCodingFast — hands-on lessons with examples you run in your browser, plus practice exercises and a quick quiz.
What You'll Learn
💡 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 int s, 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.
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.
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.
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.
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.
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.
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.
🔎 Deep Dive: the out / in mnemonic
The keywords describe the only direction the data can flow . out means T appears only in out put positions (return types) → covariant → you can swap in a derived type. in means T appears only in in put positions (parameters) → contravariant → you can swap in a base type.
- • Variance only works on interfaces and delegates , never on classes. You can't make List < Dog > covariant — use the covariant IEnumerable < Dog > instead.
- • It only applies to reference-type arguments. IEnumerable < int > is not an IEnumerable < object > , because that would require boxing.
7. 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.
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.
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 than where 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 — expose IEnumerable < 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: T is fine for one, but prefer TKey / TValue or TFirst / TSecond when 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 > to Max < 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 : class was required. Pass a reference type, or change the constraint to struct .
- "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 : struct was 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() without where T : new() . Add the new() constraint so the compiler can guarantee a parameterless constructor.
- Accidental boxing with object : storing an int in a List < object > silently boxes it onto the heap and forces a cast back out — slow and unsafe. Use List < 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 out but used T as a method input (or in but returned T ). An out T may only appear in return positions; an in T only 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
Unlocks CompareTo
new() constraint
Allows new T()
Covariance
interface IOut<out T>
Derived → base (return)
Contravariance
interface IIn<in T>
Base → derived (accept)
Safe fallback
return default(T);
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.
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 .
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.
🎉 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 / await really works under the hood
Practice quiz
What two problems do generics solve compared to storing values as object?
- Faster compilation and smaller files
- Lack of type safety and boxing of value types
- Network latency and threading
- Null references and casting strings
Answer: Lack of type safety and boxing of value types. Generics give compile-time type safety (no bad casts at runtime) and avoid boxing value types into heap objects.
What does the constraint 'where T : new()' allow you to do?
- Call CompareTo on T
- Use T as a nullable value type
- Write new T() because T is guaranteed a parameterless constructor
- Treat T as a reference type
Answer: Write new T() because T is guaranteed a parameterless constructor. The new() constraint guarantees a public parameterless constructor, which is the only reason new T() is legal.
What does 'where T : struct' restrict T to?
- Reference types only
- Value types only
- Types implementing IComparable
- Any type at all
Answer: Value types only. where T : struct restricts T to value types (int, double, DateTime), which also lets you safely use T? as a nullable value type.
What does the constraint 'where T : class' guarantee?
- T is a value type
- T has a parameterless constructor
- T is a reference type, so T can be null
- T implements IComparable
Answer: T is a reference type, so T can be null. where T : class restricts T to reference types, which means null is a legal value for T.
What does covariance (out T) allow?
- Substituting a derived type for a base type, because T is only returned
- Substituting a base type for a derived type
- Using T as both input and output
- Making a class covariant
Answer: Substituting a derived type for a base type, because T is only returned. out T means T appears only in return positions, so a producer of a derived type can stand in for a producer of the base type.
What does contravariance (in T) allow?
- Substituting a derived type for a base type
- Substituting a base type for a derived type, because T is only accepted
- Returning T from a method
- Making List<T> covariant
Answer: Substituting a base type for a derived type, because T is only accepted. in T means T appears only in parameter positions, so a consumer of a base type can stand in for a consumer of a derived type.
Variance (in/out) is allowed on which kinds of types?
- Any class
- Only interfaces and delegates
- Only structs
- Only abstract classes
Answer: Only interfaces and delegates. Variance applies only to interfaces and delegates, never to classes — you can't make List<Dog> covariant, but IEnumerable<Dog> is.
What is the value of default(T) when T is a reference type?
- 0
- An empty object
- null
- false
Answer: null. default(T) is null for reference types (and string), 0 for numbers, and false for bool — the natural zero value of the type.
Why can't you make your own List<T> covariant?
- Lists are sealed
- Because a list both returns and accepts T, so variance is not safe
- Because List is a struct
- Because lists don't implement IEnumerable
Answer: Because a list both returns and accepts T, so variance is not safe. A list both reads (returns T) and Adds (accepts T). If List<Dog> were a List<Animal>, you could Add a Cat. Variance needs one-direction flow.
What is the required order of generic constraints?
- new() first, then interfaces, then class/struct
- Interfaces first, then class/struct, then new()
- class or struct first, then interfaces/base class, then new() last
- The order does not matter
Answer: class or struct first, then interfaces/base class, then new() last. Constraint order is fixed: class or struct first, then any interfaces or base class, then new() last.
Continue this course
- Previous: Reflection
- Next: Async Internals