Just added: Algorithms you can run and practice
Updated Jun 22, 2026 Edit on GitHub

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 is T right now?" and get a real answer.
  • new T() works (with the new() constraint) - the runtime knows what T is, so it can construct one.
  • Value types avoid boxing - List<int> stores raw ints, not boxed Integer-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

  1. 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.
  2. Generic methods and classes (T First<T>(...), class Box<T>) write logic once for many types; the compiler infers T from arguments, and default(T) / default gives a generic "zero" value when you can't write a literal.
  3. Constraints (where T : class / struct / new() / IComparable<T> / SomeBase) narrow what T can be and unlock operations - like a.CompareTo(b) or new T() - those types are guaranteed to support. Violations fail at compile time.
  4. 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.
  5. Reified generics: C# keeps type info at runtime, so typeof(T), new T(), and List<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?