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.

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

Common Errors (and the fix)

📋 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

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

Related lessons