You are viewing a preview of this lesson. Sign in to start learning
Back to C# Programming

Generics & Variance

Master type parameters, constraints, and covariance/contravariance in generic types

Generics & Variance in C#

Master C# generics with free flashcards and spaced repetition practice. This lesson covers type parameters, constraints, covariance, contravariance, and generic type safetyβ€”essential concepts for building flexible, reusable code in modern C# applications.

Welcome to Advanced Type Safety πŸ’»

Generics revolutionized C# by enabling type-safe code reuse without sacrificing performance. Before generics, developers relied on object types or code duplication. Now, you can write a single method or class that works with any type while maintaining compile-time type checking. Variance takes this further by allowing you to use more derived or less derived types than originally specified, making your generic interfaces and delegates even more flexible.

In this lesson, you'll learn how to leverage C#'s powerful generic system to write cleaner, safer code. We'll explore type parameters, constraints, covariance (out), contravariance (in), and the subtle rules that govern variance in delegates and interfaces.

Core Concepts: Understanding Generics

What Are Generics?

Generics allow you to define classes, interfaces, methods, and delegates with type parametersβ€”placeholders for actual types that are specified when the generic type is used. Think of type parameters as "type variables" that get filled in later.

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚     GENERIC TYPE DEFINITION             β”‚
β”‚                                         β”‚
β”‚  class Box                           β”‚
β”‚  {                                      β”‚
β”‚      public T Value { get; set; }       β”‚
β”‚  }                                      β”‚
β”‚                                         β”‚
β”‚  ─────────────────────────────────────  β”‚
β”‚                                         β”‚
β”‚     USAGE WITH SPECIFIC TYPES           β”‚
β”‚                                         β”‚
β”‚  Box intBox = new Box();      β”‚
β”‚  Box strBox = new Box();β”‚
β”‚                                         β”‚
β”‚  βœ… Type-safe, no casting needed        β”‚
β”‚  βœ… Compile-time type checking          β”‚
β”‚  βœ… Better performance (no boxing)      β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Key benefits:

  • Type safety: Errors caught at compile-time, not runtime
  • Code reuse: Write once, use with many types
  • Performance: No boxing/unboxing for value types
  • IntelliSense support: Better tooling and autocomplete

Generic Type Parameters

Type parameters are declared in angle brackets <T> and can be named anything (conventionally starting with T):

  • T - Type (most common single parameter)
  • TKey, TValue - Key and Value types
  • TResult - Result type
  • TInput, TOutput - Input and Output types
// Generic method
public T GetFirst<T>(T[] array)
{
    return array.Length > 0 ? array[0] : default(T);
}

// Multiple type parameters
public class Dictionary<TKey, TValue>
{
    // Implementation
}

πŸ’‘ Tip: Use descriptive names for type parameters when you have multipleβ€”it makes code more readable!

Generic Constraints

Constraints limit which types can be used as type arguments. They're specified with the where keyword and enable you to call methods or access properties that aren't available on all types.

ConstraintSyntaxMeaning
Class constraintwhere T : ClassNameT must be or derive from ClassName
Interface constraintwhere T : IInterfaceT must implement IInterface
Struct constraintwhere T : structT must be a value type
Class constraintwhere T : classT must be a reference type
New constraintwhere T : new()T must have parameterless constructor
Unmanaged constraintwhere T : unmanagedT must be an unmanaged type
Notnull constraintwhere T : notnullT must be non-nullable (C# 8+)
// Multiple constraints
public class Repository<T> where T : class, IEntity, new()
{
    public T Create()
    {
        return new T(); // new() constraint allows this
    }
    
    public int GetId(T entity)
    {
        return entity.Id; // IEntity constraint allows this
    }
}

⚠️ Constraint order matters: class or struct must come first, then base class, then interfaces, then new().

Understanding Variance

Variance is about assignment compatibility between generic types when their type arguments have inheritance relationships. It answers the question: "If Dog derives from Animal, can I use List<Dog> where List<Animal> is expected?"

Invariance (Default)

By default, generic types are invariantβ€”no conversion is allowed between Generic<Derived> and Generic<Base>, even if Derived inherits from Base.

class Animal { }
class Dog : Animal { }

List<Dog> dogs = new List<Dog>();
List<Animal> animals = dogs; // ❌ ERROR: Cannot convert

Why? Safety! If this were allowed:

animals.Add(new Cat()); // Would add a Cat to a List<Dog>!

Invariance prevents this type safety violation.

Covariance (out)

Covariance allows you to use a more derived type than originally specified. It's enabled with the out keyword and only works for type parameters used as output (return types).

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚         COVARIANCE FLOW              β”‚
β”‚                                      β”‚
β”‚    IEnumerable dogs             β”‚
β”‚           β”‚                          β”‚
β”‚           β”‚ βœ… Can assign to          β”‚
β”‚           ↓                          β”‚
β”‚    IEnumerable animals       β”‚
β”‚                                      β”‚
β”‚    More specific β†’ More general      β”‚
β”‚    (You can get Animals from Dogs)   β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
// IEnumerable<T> is covariant
IEnumerable<Dog> dogs = new List<Dog>();
IEnumerable<Animal> animals = dogs; // βœ… OK: Covariance

// foreach only reads, never writes
foreach (Animal animal in animals)
{
    Console.WriteLine(animal.Name);
}

Why is this safe? IEnumerable<T> only returns T valuesβ€”it never accepts them as input. Since every Dog is an Animal, returning dogs as animals is perfectly safe.

🧠 Memory device: "out of the box"β€”covariant type parameters produce output but don't consume input.

Contravariance (in)

Contravariance allows you to use a less derived type than originally specified. It's enabled with the in keyword and only works for type parameters used as input (method parameters).

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚       CONTRAVARIANCE FLOW            β”‚
β”‚                                      β”‚
β”‚    IComparer animalCmp       β”‚
β”‚           β”‚                          β”‚
β”‚           β”‚ βœ… Can assign to          β”‚
β”‚           ↓                          β”‚
β”‚    IComparer dogCmp             β”‚
β”‚                                      β”‚
β”‚    More general β†’ More specific      β”‚
β”‚    (Can compare Dogs using Animal    β”‚
β”‚     comparison logic)                β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
// Action<T> is contravariant
Action<Animal> actOnAnimal = (animal) => Console.WriteLine(animal.Name);
Action<Dog> actOnDog = actOnAnimal; // βœ… OK: Contravariance

actOnDog(new Dog()); // Calls actOnAnimal with a Dog

Why is this safe? If a method can handle any Animal, it can certainly handle a Dog (which is an Animal). The method only consumes T values, never produces them.

🧠 Memory device: "in the method"β€”contravariant type parameters accept input but don't produce output.

Variance Rules Summary

πŸ“‹ Variance Quick Reference

VarianceKeywordUsageExample
Invariance(none)Read & writeList<T>
CovarianceoutReturn values onlyIEnumerable<out T>
ContravarianceinMethod parameters onlyIComparer<in T>

⚠️ Critical: Variance only applies to interfaces and delegates, not to classes or structs!

Examples with Detailed Explanations

Example 1: Building a Generic Stack

Let's create a type-safe generic stack with constraints:

public class Stack<T> where T : IComparable<T>
{
    private T[] items;
    private int count;
    
    public Stack(int capacity = 10)
    {
        items = new T[capacity];
        count = 0;
    }
    
    public void Push(T item)
    {
        if (count == items.Length)
            Array.Resize(ref items, items.Length * 2);
        items[count++] = item;
    }
    
    public T Pop()
    {
        if (count == 0)
            throw new InvalidOperationException("Stack is empty");
        return items[--count];
    }
    
    public T PeekMax()
    {
        if (count == 0)
            throw new InvalidOperationException("Stack is empty");
        
        T max = items[0];
        for (int i = 1; i < count; i++)
        {
            if (items[i].CompareTo(max) > 0) // IComparable<T> constraint
                max = items[i];
        }
        return max;
    }
}

// Usage
Stack<int> numbers = new Stack<int>();
numbers.Push(5);
numbers.Push(10);
numbers.Push(3);
Console.WriteLine(numbers.PeekMax()); // Output: 10

Explanation: The IComparable<T> constraint allows us to call CompareTo() in the PeekMax() method. Without this constraint, the compiler wouldn't know that T supports comparison. This demonstrates how constraints unlock functionality while maintaining type safety.

Example 2: Covariance with Interfaces

Here's how covariance enables flexible code with read-only collections:

public interface IProducer<out T>
{
    T Produce();
}

public class AnimalShelter : IProducer<Animal>
{
    public Animal Produce() => new Animal { Name = "Generic Animal" };
}

public class DogKennel : IProducer<Dog>
{
    public Dog Produce() => new Dog { Name = "Buddy", Breed = "Golden" };
}

public void ProcessAnimals(IProducer<Animal> producer)
{
    Animal animal = producer.Produce();
    Console.WriteLine($"Received: {animal.Name}");
}

// Usage
DogKennel kennel = new DogKennel();
ProcessAnimals(kennel); // βœ… OK: IProducer<Dog> β†’ IProducer<Animal>

Explanation: The out keyword on IProducer<out T> makes it covariant. Since Dog derives from Animal, we can pass an IProducer<Dog> where IProducer<Animal> is expected. This is safe because Produce() only returns valuesβ€”it never accepts T as input. The returned Dog can always be treated as an Animal.

Example 3: Contravariance with Delegates

Contravariance shines when working with event handlers and callbacks:

public class Animal
{
    public string Name { get; set; }
    public void MakeSound() => Console.WriteLine("Some sound");
}

public class Dog : Animal
{
    public string Breed { get; set; }
}

public delegate void Handler<in T>(T item);

public class EventProcessor<T>
{
    public Handler<T> Callback { get; set; }
    
    public void Process(T item)
    {
        Callback?.Invoke(item);
    }
}

// Usage
Handler<Animal> animalHandler = (animal) =>
{
    Console.WriteLine($"Handling animal: {animal.Name}");
    animal.MakeSound();
};

EventProcessor<Dog> dogProcessor = new EventProcessor<Dog>();
dogProcessor.Callback = animalHandler; // βœ… OK: Contravariance

dog.Processor.Process(new Dog { Name = "Max", Breed = "Labrador" });

Explanation: The in keyword on Handler<in T> makes it contravariant. A handler that can process any Animal can certainly process a specific Dog. This is safe because the delegate only consumes T as inputβ€”it never returns a T value. Contravariance allows more general handlers to work with more specific types.

Example 4: Generic Method with Multiple Constraints

Combining constraints creates powerful, safe utility methods:

public class Repository
{
    public T FindOrCreate<T>(int id, Func<int, T> factory)
        where T : class, IEntity, new()
    {
        T existing = Database.Find<T>(id);
        if (existing != null)
            return existing;
        
        // Use factory if provided, otherwise use new()
        T newEntity = factory != null ? factory(id) : new T();
        newEntity.Id = id; // IEntity allows this
        Database.Save(newEntity); // class constraint ensures reference type
        return newEntity;
    }
}

public interface IEntity
{
    int Id { get; set; }
}

public class User : IEntity
{
    public int Id { get; set; }
    public string Username { get; set; }
    
    public User() { } // Required by new() constraint
}

// Usage
var repo = new Repository();
User user = repo.FindOrCreate<User>(123, null);

Explanation: This method showcases multiple constraints working together:

  • class: Ensures T is a reference type (can be null)
  • IEntity: Allows accessing the Id property
  • new(): Enables creating instances with new T()

The constraints must appear in this specific order. This pattern is common in repositories and factories where you need to both create and manipulate instances.

Common Mistakes ⚠️

Mistake 1: Forgetting Variance Only Works with Interfaces/Delegates

// ❌ WRONG: Classes cannot be variant
public class MyClass<out T>
{
    // Error: Variance not valid on classes
}

// βœ… RIGHT: Use interfaces
public interface IMyInterface<out T>
{
    T GetValue();
}

Why it matters: The C# compiler only supports variance for interfaces and delegates because they don't store state directly. Classes with covariant type parameters could violate type safety through fields.

Mistake 2: Using 'out' Parameters with Input Methods

// ❌ WRONG: Covariant parameter in input position
public interface IProcessor<out T>
{
    void Process(T item); // Error: T in input position
}

// βœ… RIGHT: Use invariant or contravariant
public interface IProcessor<in T>
{
    void Process(T item); // OK: Contravariant
}

public interface IProcessor<T> // OK: Invariant
{
    void Process(T item);
}

Why it matters: Covariance only works when T appears in output positions (return types). Using it in input positions would break type safety.

Mistake 3: Assuming List is Covariant

// ❌ WRONG: List<T> is invariant
List<Dog> dogs = new List<Dog>();
List<Animal> animals = dogs; // Error!

// βœ… RIGHT: Use IEnumerable<T> for reading
IEnumerable<Dog> dogs = new List<Dog>();
IEnumerable<Animal> animals = dogs; // OK: Covariant

// βœ… RIGHT: Cast explicitly if you need List
List<Animal> animals = dogs.Cast<Animal>().ToList();

Why it matters: List<T> allows both reading and writing, so it must be invariant. Use IEnumerable<T> (covariant) when you only need to read, or explicitly convert when necessary.

Mistake 4: Circular Constraints

// ❌ WRONG: Circular constraint
public class Node<T> where T : Node<T>
{
    public T Next { get; set; }
}

// βœ… RIGHT: Self-referencing with proper design
public class Node<T> where T : Node<T>
{
    public T Next { get; set; }
    public T GetLast()
    {
        T current = (T)this;
        while (current.Next != null)
            current = current.Next;
        return current;
    }
}

Why it matters: While circular constraints can work (like the Curiously Recurring Template Pattern), they're complex and easy to misuse. Ensure your design genuinely benefits from self-referencing.

Mistake 5: Ignoring the 'new()' Constraint Order

// ❌ WRONG: Incorrect constraint order
public class Factory<T> where T : new(), IDisposable
{
    // Error: new() must come last
}

// βœ… RIGHT: Correct order
public class Factory<T> where T : IDisposable, new()
{
    public T Create()
    {
        return new T();
    }
}

Why it matters: The C# compiler requires constraints in a specific order: class/struct constraint first, then base class, then interfaces, then new() last. Violating this order causes compilation errors.

Did You Know? πŸ€”

  • Before generics (C# 1.0), developers used ArrayList which stored everything as object. This caused frequent runtime casting errors and boxing overhead. Generics were introduced in C# 2.0 (2005) and transformed the language.

  • Variance keywords (in and out) were added in C# 4.0 (2010), inspired by similar features in other languages like Scala.

  • The Func<> delegates are covariant in their return type and contravariant in their parameters: Func<in T, out TResult>. This makes them incredibly flexible for functional programming patterns.

  • Generic type parameters can themselves be constrained by other type parameters: where T : IComparable<U> creates interesting relationships between types.

Key Takeaways 🎯

βœ… Generics enable type-safe code reuse without performance penalties from boxing or casting

βœ… Constraints (where) unlock functionality by guaranteeing type capabilities at compile time

βœ… Covariance (out) allows more derived types in output positionsβ€”think "producing" values

βœ… Contravariance (in) allows less derived types in input positionsβ€”think "consuming" values

βœ… Variance only applies to interfaces and delegates, not classes or structs

βœ… Invariance is the safe default when types are used for both input and output

βœ… Generic methods can infer types from arguments, making them convenient to call

βœ… Multiple constraints can combine to create powerful, safe abstractions

πŸ“‹ Quick Reference Card: Generics & Variance

ConceptSyntaxUse When
Generic classclass Box<T>Reusable type-safe containers
Generic methodT Get<T>()Type-agnostic operations
Class constraintwhere T : BaseClassNeed base class methods
Interface constraintwhere T : IInterfaceNeed interface methods
Struct constraintwhere T : structOnly value types allowed
Class constraintwhere T : classOnly reference types allowed
New constraintwhere T : new()Need to instantiate T
Covarianceinterface I<out T>Return T, never accept as input
Contravarianceinterface I<in T>Accept T as input, never return
Invarianceclass C<T>Read and write T (default)

Memory Devices:

  • 🧠 OUTput = Covariance (produces values)
  • 🧠 INput = Contravariance (consumes values)
  • 🧠 "new() comes new-ly" (last in constraint order)
  • 🧠 "Interfaces are INterchangeable" (support variance)

Further Study πŸ“š

Deepen your understanding of C# generics and variance with these resources:

  1. Microsoft Docs - Generics (C# Programming Guide)
    https://learn.microsoft.com/en-us/dotnet/csharp/fundamentals/types/generics
    Comprehensive official documentation with examples and best practices

  2. Microsoft Docs - Covariance and Contravariance
    https://learn.microsoft.com/en-us/dotnet/csharp/programming-guide/concepts/covariance-contravariance/
    Detailed explanation of variance with real-world scenarios

  3. C# in Depth by Jon Skeet - Chapter on Generics
    https://csharpindepth.com/articles/
    Deep dive into the subtleties of generic type system design

Continue practicing with real code examples to master these powerful features. Generics and variance are foundational to modern C# development, from LINQ to dependency injection frameworks! πŸ’»βœ¨