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.

Learn Python, JavaScript, Java and more with free interactive lessons, real projects and AI-powered help. Beginner-friendly.

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.

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.

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 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.

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.

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.

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.

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.

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.

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.