Generics, Deep - Type Safety Without Duplication
Back in Phase 3 you used List<T> the way everyone first meets generics: you wrote List<string>, it held strings, and the compiler stopped you shoving an int in - you took the <T> on faith. This phase covers what that angle-bracket actually is.
The mental model: a generic is code with a hole in it where a type goes. You write the logic once, leave the type as a blank labeled T, and the compiler fills that blank - string, int, User, whatever - when you use it. Write it once, keep full compile-time type safety, pay zero runtime cost.
Why generics exist - the bad old days of object
Before generics, a list that could hold anything meant using object - the type every other type inherits from. It compiles fine. It's also a trap.
// Pre-generics: a list of `object` holds anything... which is the problem.
var things = new System.Collections.ArrayList();
things.Add(42);
things.Add("not a number"); // compiler is fine with this - uh oh
int first = (int)things[0]; // cast back: works
int second = (int)things[1]; // cast back: BOOM at runtime
Unhandled exception. System.InvalidCastException: Unable to cast object
of type 'System.String' to type 'System.Int32'.
What just happened: ArrayList stores everything as object, so it happily accepted both an int and a string - the compiler had no idea the second item wasn't a number. The mistake surfaced only at runtime, when the cast on things[1] blew up in production. There's a quieter cost too: stuffing 42 into an object slot boxes it - wraps it in a heap-allocated object - and casting it back unboxes it, an allocation and a copy for every value-type item.
Generics fix both problems at once: List<int> knows its contents are ints, so the mistake becomes a compile error and the boxing never happens - the ints are stored raw.
var numbers = new List<int>();
numbers.Add(42);
numbers.Add("not a number"); // ⚠️ compile error - caught before you run
int first = numbers[0]; // no cast, no boxing
error CS1503: Argument 1: cannot convert from 'string' to 'int'
What just happened: List<int> carries its element type in the type itself, so Add("not a number") is rejected at compile time - the bug can never reach a user. Because the list genuinely holds ints, not boxed objects, reading numbers[0] needs no cast and no heap allocation. object gives you flexibility by throwing away type information; generics give you flexibility while keeping it.
Generic methods and classes - write the logic once
A generic puts a type parameter - conventionally T - into a method or class signature. Inside, T stands in for "whatever type the caller used"; the compiler checks the body against that placeholder and substitutes the real type at each call.
📝 Type parameter - a named placeholder for a type, written in angle brackets (<T>), filled in by the caller or the compiler's inference. By convention single letters: T for "type," TKey/TValue for paired roles, TResult for a return.
A generic method - "give me the first item" works for a list of anything:
// <T> declares the placeholder; it then appears in the parameter and return types.
T First<T>(List<T> items)
{
return items[0];
}
var words = new List<string> { "alpha", "beta" };
var sizes = new List<int> { 10, 20, 30 };
string w = First(words); // T inferred as string - note: no First<string>(...) needed
int n = First(sizes); // T inferred as int
Console.WriteLine($"{w}, {n}");
alpha, 10
What just happened: First<T> is one method that works for any element type. You didn't write First<string>(words) - the compiler performed type inference, saw words was a List<string>, deduced T must be string, and filled it in, so w comes back a real string with no cast needed.
A generic class - a little box that holds one value of whatever type you choose:
class Box<T>
{
private T _value;
public Box(T value) => _value = value;
public T Get() => _value;
// default(T): the "zero" value for whatever T is.
public bool IsDefault() => EqualityComparer<T>.Default.Equals(_value, default(T));
}
var boxedInt = new Box<int>(0);
var boxedName = new Box<string>("Ada");
Console.WriteLine($"{boxedInt.IsDefault()} / {boxedName.Get()}");
True / Ada
What just happened: Box<T> is a class with a type-shaped hole - new Box<int>(0) stamps out a box holding an int; new Box<string>("Ada") a box holding a string. The interesting bit is default(T): every type has a default value - 0 for int, false for bool, null for reference types - and default(T) gives you that value generically, without knowing what T is. We used it to check whether the box holds its type's "zero."
💡 Key point. default(T) matters because inside a generic you often need a starting value but can't write a literal - you don't know if T is a number, a string, or a struct. Modern C# lets you shorten default(T) to just default wherever the target type is already known.
Constraints - telling the compiler what T can do
There's a catch in First<T> and Box<T>: the compiler assumes T could be literally any type, so it only lets you do things every type supports - store it, pass it around, call .ToString(). Try a > b, new T(), or a.SomeMethod() and it refuses, since not every type has those.
The fix is a constraint: a where clause that narrows what T is allowed to be, which in turn unlocks the operations that narrower set of types supports.
📝 Constraint - a where T : ... clause restricting which types can be used for T. A two-way promise: you limit the callers' choices, and in exchange the compiler lets you use the capabilities all allowed types are guaranteed to have.
The common constraints:
| Constraint | Means "T must be…" | Unlocks |
|---|---|---|
where T : class |
a reference type | comparing to null, null defaults |
where T : struct |
a value type (non-nullable) | value semantics; T is never null |
where T : new() |
a type with a public parameterless constructor | calling new T() |
where T : IComparable<T> |
a type implementing that interface | calling .CompareTo(...) |
where T : SomeBaseClass |
that class or a subclass | that base's members |
Without a constraint you can't write a generic "max" - the compiler can't assume T is comparable. Add where T : IComparable<T> and it works:
// IComparable<T> guarantees a.CompareTo(b) exists, which is what unlocks the comparison.
T Max<T>(T a, T b) where T : IComparable<T>
{
return a.CompareTo(b) >= 0 ? a : b;
}
Console.WriteLine(Max(3, 9)); // works for int (int : IComparable<int>)
Console.WriteLine(Max("apple", "pear")); // works for string too
9
pear
What just happened: a.CompareTo(b) returns a number - negative if a is smaller, positive if bigger, zero if equal. That method exists only because we promised, via where T : IComparable<T>, that every T implements it. int and string both do, so both calls compile. Drop the where clause and the compiler rejects a.CompareTo(b) outright - T might otherwise be a type that can't be compared at all.
The new() constraint lets a generic manufacture instances:
// new() promises T has a parameterless constructor, so `new T()` is allowed.
T MakeOne<T>() where T : new()
{
return new T();
}
var freshList = MakeOne<List<int>>(); // calls new List<int>()
Console.WriteLine(freshList.Count);
0
What just happened: new T() is normally forbidden in a generic - the compiler can't know T even has a usable constructor. The where T : new() constraint guarantees it does, so new T() becomes legal.
When a caller violates a constraint, the error lands at compile time:
class NoDefaultCtor
{
public NoDefaultCtor(int required) { } // only constructor needs an argument
}
var bad = MakeOne<NoDefaultCtor>(); // ⚠️ no parameterless constructor
error CS0310: 'NoDefaultCtor' must be a non-abstract type with a public
parameterless constructor in order to use it as parameter 'T' in 'MakeOne<T>()'
What just happened: NoDefaultCtor only has a constructor that requires an argument, so it fails the new() constraint. The compiler refuses the call and tells you why - T doesn't meet the promise the method depends on.
Covariance and contravariance - the genuinely tricky bit
A Cat is an Animal. So a List<Cat> is a List<Animal>… right? In C#, no - and the reason matters.
⚠️ The intuition is half true. Whether Something<Cat> can stand in for Something<Animal> depends on whether that "something" only ever hands values out or also takes values in.
Imagine the substitution were always allowed - you hand your List<Cat> to code that thinks it has a List<Animal>:
List<Cat> cats = new List<Cat> { new Cat() };
List<Animal> animals = cats; // PRETEND this were allowed...
animals.Add(new Dog()); // ...a Dog, into a list that's really all Cats. Corruption.
Cat c = cats[0]; // your "list of cats" now contains a Dog
What just happened (in this hypothetical): if List<Cat> could masquerade as List<Animal>, code holding the List<Animal> view could legally Add a Dog - because a Dog is an Animal. But the underlying list is genuinely all Cats, so you've smuggled a Dog into it. That's why C# forbids the assignment: List<T> lets you write into it, and writing is where widening goes wrong.
Now flip it: a type that only ever produces values can widen safely - a thing that hands you Cats can be treated as a thing that hands you Animals, since every Cat it gives out is an Animal. That's covariance, and IEnumerable<T> (read-only iteration) is declared this way with the keyword out:
IEnumerable<Cat> cats = new List<Cat> { new Cat(), new Cat() };
IEnumerable<Animal> animals = cats; // ALLOWED - IEnumerable<out T> is covariant
foreach (Animal a in animals) // every Cat handed out is, indeed, an Animal
Console.WriteLine(a.GetType().Name);
Cat
Cat
What just happened: IEnumerable<T> is declared IEnumerable<out T> - out marks T as covariant, "this type only ever produces T, never consumes one." Because iteration only reads items out, treating an IEnumerable<Cat> as an IEnumerable<Animal> is safe: every value pulled is a Cat, which is an Animal, and there's no Add to corrupt anything.
The mirror image is contravariance: a type that only ever consumes values can be treated as one that accepts a narrower type. Action<in T> (a function taking a T, returning nothing) is the classic case:
// A consumer of Animals: it can handle ANY animal handed to it.
Action<Animal> describe = a => Console.WriteLine($"an {a.GetType().Name}");
// We need something that consumes Cats. An Animal-consumer qualifies - it eats cats too.
Action<Cat> describeCat = describe; // ALLOWED - Action<in T> is contravariant
describeCat(new Cat());
an Cat
What just happened: Action<T> is declared Action<in T> - in marks T as contravariant, "this type only ever consumes T." We needed something that consumes Cats; describe consumes any Animal, so it handles a Cat fine. Consumers can safely narrow.
One sentence covers it: out = produces-only = can widen (covariant); in = consumes-only = can narrow (contravariant); both = invariant, no substitution. List<T> does both, which is why it's invariant and the first example was illegal.
⚠️ Gotcha - arrays are unsafely covariant, a historical wart. Arrays do allow Animal[] a = new Cat[2];, even though arrays are writable. C# inherited this from early .NET (before generics existed) for compatibility, and it's a known design mistake: every array write carries a hidden runtime type check, and writing the wrong type throws ArrayTypeMismatchException instead of failing at compile time. Generics learned from this - List<T> is invariant so the same bug can't happen. Treat array covariance as a trap, not a feature.
C# generics are reified - a real edge over Java
Java implements generics by type erasure: List<String> and List<Integer> are the same type at runtime - the <String> is a compile-time fiction deleted before the program runs. That's why Java can't do new T(), can't ask T.class, and boxes every int into an Integer.
C# made the opposite choice: generics are reified, the type information is real at runtime, baked into the actual type.
📝 Reified generics - generic type information that survives to runtime, rather than being erased after compilation. In C#, List<int> and List<string> are genuinely distinct types at runtime, and T is a real, queryable type inside generic code.
💡 Key point. Three consequences fall out of reification, each impossible under Java's erasure:
typeof(T)works - ask, at runtime, "what type isTright now?" and get a real answer.new T()works (with thenew()constraint) - the runtime knows whatTis, so it can construct one.- Value types avoid boxing -
List<int>stores rawints, not boxedInteger-style objects. Java must box, since erased generics can't hold a primitive.
void Inspect<T>() where T : new()
{
T instance = new T(); // reification: runtime can build a T
Console.WriteLine($"T is {typeof(T).Name}"); // and tell you what T is
Console.WriteLine($"made: {instance}");
}
Inspect<List<int>>();
// And distinct runtime types - not the case in Java:
Console.WriteLine(typeof(List<int>) == typeof(List<string>));
T is List`1
made: System.Collections.Generic.List`1[System.Int32]
False
What just happened: inside Inspect<T>, both typeof(T) and new T() work because the runtime genuinely knows what T is. The final comparison prints False: List<int> and List<string> are different runtime types. (List`1 is .NET's name for "a generic List with 1 type parameter.") Under Java's erasure, T would be unknowable, new T() impossible, List<Integer>/List<String> indistinguishable.
Recap
- Why generics: the old
object-and-cast approach pushed type errors to runtime and boxed every value type. Generics keep type information, so mistakes become compile errors and value types stay unboxed. - Generic methods and classes (
T First<T>(...),class Box<T>) write logic once for many types; the compiler infersTfrom arguments, anddefault(T)/defaultgives a generic "zero" value when you can't write a literal. - Constraints (
where T : class / struct / new() / IComparable<T> / SomeBase) narrow whatTcan be and unlock operations - likea.CompareTo(b)ornew T()- those types are guaranteed to support. Violations fail at compile time. - Covariance (
out) and contravariance (in): a producer-only type (IEnumerable<out T>) can safely widen; a consumer-only type (Action<in T>) can safely narrow.List<T>reads and writes, so it's invariant. Arrays are unsafely covariant - a historical wart that throws at runtime. - Reified generics: C# keeps type info at runtime, so
typeof(T),new T(), andList<int>≠List<string>all work, and value types avoid boxing - an advantage over Java's erasure.
You can now write code with type-shaped holes the compiler fills safely and for free. Next: delegates, lambdas, and events - functions as values, the engine behind LINQ and most of the .NET event model.
Quick check
Test yourself on the three ideas that matter most - constraints, variance, and reification:
[
{
"q": "Why does `T Max<T>(T a, T b)` need the `where T : IComparable<T>` constraint?",
"choices": [
"Without it, the compiler can't guarantee `T` supports `.CompareTo(...)`, so the comparison in the body wouldn't be allowed",
"It makes the method run faster by skipping a runtime type check",
"It forces `T` to be a value type so it can be stored on the stack",
"It's optional - the method compiles fine with no constraint at all"
],
"answer": 0,
"explain": "A constraint both restricts which types `T` may be and unlocks that capability inside the body. `IComparable<T>` provides `.CompareTo(...)`; without the constraint, `T` could be a non-comparable type, so the call would be rejected at compile time."
},
{
"q": "Why does C# allow `IEnumerable<Animal> a = someIEnumerableOfCat;` but forbid the same assignment for `List<T>`?",
"choices": [
"`IEnumerable<out T>` only produces values (covariant), so widening is safe; `List<T>` also writes, so widening could smuggle a wrong type in",
"`IEnumerable` is a class and `List` is an interface, and only classes support variance",
"It's an arbitrary compiler rule with no real reason behind it",
"`List<T>` is covariant too - the assignment actually is allowed"
],
"answer": 0,
"explain": "Covariance (`out`) is safe only for producer-only types: every value handed out is a Cat, which is an Animal. `List<T>` reads AND writes, so treating a `List<Cat>` as `List<Animal>` would let code Add a Dog into it - which is why `List<T>` is invariant."
},
{
"q": "What does C#'s 'reified generics' (vs Java's type erasure) let you do that Java cannot?",
"choices": [
"Use `typeof(T)` and `new T()` at runtime, and store value types like `int` without boxing - because the type info survives to runtime",
"Write generic methods with type parameters at all - Java has no generics",
"Run generic code faster by deleting type information before execution",
"Make `List<int>` and `List<string>` the same runtime type for compatibility"
],
"answer": 0,
"explain": "C# keeps generic type info at runtime (reification), so `typeof(T)` and `new T()` work and `List<int>` holds raw unboxed ints. Java erases generics, making T unknowable at runtime, forbidding `new T()`, and forcing boxing of primitives."
}
]
Before the quiz: without looking back, say (or jot down) the core idea of this phase in your own words.
Check your understanding 3 questions
1. Why does `T Max<T>(T a, T b)` need the `where T : IComparable<T>` constraint?
2. Why does C# allow `IEnumerable<Animal> a = someIEnumerableOfCat;` but forbid the same assignment for `List<T>`?
3. What does C#'s 'reified generics' (vs Java's type erasure) let you do that Java cannot?