Skip to main content

📝 Lesson 1.1: Generic Variance and Advanced Constraints

Why can you assign an IEnumerable⟨Dog⟩ to an IEnumerable⟨Animal⟩, but not a List⟨Dog⟩ to a List⟨Animal⟩? The answer is variance — one of the subtlest, most powerful parts of C#'s type system.

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Explain covariance, contravariance, and invariance
  • Use the out and in keywords on generic type parameters
  • Recognize variance in the .NET interfaces you use daily
  • Declare variance on your own generic interfaces and delegates
  • Apply advanced generic constraints beyond the basics

Estimated Time: 75 minutes

Project: Design variance-aware interfaces and constrained generic methods.

In This Lesson

The Variance Puzzle

Given a class hierarchy where Dog derives from Animal (Intermediate/OOP), it feels natural that "a sequence of dogs is a sequence of animals." And indeed:

IEnumerable<Dog> dogs = new List<Dog> { new Dog(), new Dog() };
IEnumerable<Animal> animals = dogs;   // ✅ allowed — IEnumerable is covariant

But this closely-related assignment is a compile error:

List<Dog> dogList = new List<Dog>();
List<Animal> animalList = dogList;    // ❌ error — List is invariant

📖 Definition

Variance describes how the subtyping of generic type arguments relates to the subtyping of the generic type itself — whether Wrapper<Dog> is compatible with Wrapper<Animal>.

There are three cases, and the difference comes down to what the type does with T — produce it, consume it, or both:

KindKeywordRelationshipRole of T
Covarianceoutpreserves direction (Dog → Animal)output only (producer)
Contravarianceinreverses direction (Animal → Dog)input only (consumer)
Invariance(none)no compatibilityboth input and output

Covariance (out)

Covariance lets a generic type flow in the same direction as its type argument: because Dog is an Animal, an IEnumerable<Dog> is usable as an IEnumerable<Animal>. It's marked with out on the type parameter.

Look at the actual .NET declaration:

public interface IEnumerable<out T> : IEnumerable   // note: out T
{
    IEnumerator<T> GetEnumerator();
}

The out is a promise: "T only ever comes out of this interface (as return values), never goes in (as parameters)." That's what makes it safe — every Dog that comes out is a valid Animal, so treating the sequence as IEnumerable<Animal> can never break.

graph LR A["Dog derives from Animal"] --> B["IEnumerable of Dog
(out T = producer)"] B -->|"usable as"| C["IEnumerable of Animal"] style A fill:#eff6ff,stroke:#3b82f6,stroke-width:2px style C fill:#e8f5e9,stroke:#4CAF50,stroke-width:2px

✅ Covariant types you already use

IEnumerable<out T>, IReadOnlyList<out T>, IReadOnlyCollection<out T>, and Func<out TResult> (its result is covariant). Notice the pattern: read-only / producing types are covariant. A Func<Dog> can be used where a Func<Animal> is expected, because whatever it returns is an Animal.

Contravariance (in)

Contravariance is the mirror image: the generic type flows in the opposite direction of its type argument. It applies to types that only consume T as input, and is marked with in.

Consider IComparer<in T>, which compares two T values (input only):

public interface IComparer<in T>   // note: in T
{
    int Compare(T x, T y);
}

Here's the mind-bending but logical part: a comparer that can compare any Animal can certainly compare two Dogs. So an IComparer<Animal> is usable as an IComparer<Dog> — the reverse of covariance:

IComparer<Animal> animalComparer = new AnimalComparer();
IComparer<Dog> dogComparer = animalComparer;   // ✅ contravariance (in)
graph LR A["Dog derives from Animal"] --> B["IComparer of Animal
(in T = consumer)"] B -->|"usable as"| C["IComparer of Dog"] style A fill:#eff6ff,stroke:#3b82f6,stroke-width:2px style C fill:#e8f5e9,stroke:#4CAF50,stroke-width:2px

✅ Contravariant types you already use

IComparer<in T>, IEqualityComparer<in T>, and Action<in T> (its parameter is contravariant). An Action<Animal> can be used as an Action<Dog> — if it can act on any Animal, it can act on a Dog.

💡 Remember it as PECS-in-C#: out = comes out = producer = covariant. in = goes in = consumer = contravariant. The keyword literally tells you the direction T travels.

Invariance and Safety

A type that both accepts and returns T can't be safely varied in either direction — it's invariant. List<T> is the classic example: you can add a T (input) and read a T (output).

Here's why allowing List<Dog>List<Animal> would be dangerous:

List<Dog> dogs = new List<Dog>();
List<Animal> animals = dogs;      // IF this were allowed...
animals.Add(new Cat());           // ...you could add a Cat to a list of Dogs! 💥

That would let a Cat sneak into a List<Dog>, breaking type safety. To prevent exactly this, List<T> is invariant — the compiler forbids the assignment in the first place.

💡 The rule the compiler enforces

A type parameter can be covariant (out) only if it's never used as an input, and contravariant (in) only if it's never used as an output. If it's used as both, it must stay invariant. This is why variance is only allowed on interfaces and delegates — never on classes like List<T>, which typically do both.

Declaring Your Own Variance

You can add variance to your own generic interfaces and delegates when it makes sense. Suppose a read-only producer interface:

// Covariant: T only appears as a return value
public interface IProducer<out T>
{
    T Produce();
}

public class DogProducer : IProducer<Dog>
{
    public Dog Produce() => new Dog();
}

// Because of 'out', this assignment now works:
IProducer<Animal> animalProducer = new DogProducer();
Animal a = animalProducer.Produce();   // ✅

And a consumer interface, which can be contravariant:

// Contravariant: T only appears as a parameter
public interface IConsumer<in T>
{
    void Consume(T item);
}

IConsumer<Animal> animalConsumer = new AnimalConsumer();
IConsumer<Dog> dogConsumer = animalConsumer;   // ✅

⚠️ The compiler holds you to the promise

If you mark a parameter out T and then try to use T as a method input (or in T as a return type), you'll get a compile error. The variance annotation is a contract the compiler verifies — you can't accidentally break the safety it provides.

Advanced Constraints

You met basic constraints (where T : IComparable<T>, : class, : new()) in Intermediate. Here are the more advanced options and combinations:

ConstraintRequires T to be…
where T : notnulla non-nullable type (value or reference)
where T : structa non-nullable value type
where T : classa reference type
where T : unmanagedan unmanaged value type (no references inside) — useful for interop/perf
where T : Enuman enum type
where T : BaseClass, IInterface, new()satisfy several constraints at once

Constraints combine and stack. Order matters: a base class comes first, then interfaces, then new() last:

public T CreateAndTag<T>(string tag)
    where T : Entity, ITaggable, new()
{
    T item = new T();          // allowed by new()
    item.Tag = tag;            // allowed by ITaggable
    return item;               // T is an Entity, guaranteed
}

Multiple type parameters can each have their own constraints:

public TResult Map<TSource, TResult>(TSource source, Func<TSource, TResult> f)
    where TSource : notnull
    where TResult : class
{
    return f(source);
}

💡 Constraints unlock capabilities

Each constraint tells the compiler what T can do, so you can call those members. where T : struct lets you use T? as Nullable<T>; new() lets you write new T(); a base-class constraint lets you access that base's members. Without the constraint, the compiler assumes nothing and forbids the operation.

Exercise & Quiz

🏋️ Exercise: Variance-Aware Repository

Objective: Design covariant and contravariant interfaces and see the assignments they enable.

Instructions:

  1. Create a new project called Variance. Define a base class Animal and class Dog : Animal.
  2. Define interface IReadRepository<out T> with T GetFirst(); (covariant — output only).
  3. Define interface IWriter<in T> with void Write(T item); (contravariant — input only).
  4. Implement DogRepository : IReadRepository<Dog> and AnimalWriter : IWriter<Animal>.
  5. Show that IReadRepository<Animal> r = new DogRepository(); and IWriter<Dog> w = new AnimalWriter(); both compile, and explain why in a comment.

Starter Code:

class Animal { public string Name { get; set; } = "?"; }
class Dog : Animal { }

interface IReadRepository<out T>
{
    T GetFirst();
}

interface IWriter<in T>
{
    void Write(T item);
}

// TODO: implement DogRepository and AnimalWriter, then do the variant assignments
💡 Hint

Covariance (out) lets a producer of a more derived type stand in for a producer of a base type (Dog repo → Animal repo). Contravariance (in) lets a consumer of a base type stand in for a consumer of a derived type (Animal writer → Dog writer).

✅ Solution
class Animal { public string Name { get; set; } = "?"; }
class Dog : Animal { }

interface IReadRepository<out T>
{
    T GetFirst();
}

interface IWriter<in T>
{
    void Write(T item);
}

class DogRepository : IReadRepository<Dog>
{
    public Dog GetFirst() => new Dog { Name = "Rex" };
}

class AnimalWriter : IWriter<Animal>
{
    public void Write(Animal item) => Console.WriteLine($"Writing {item.Name}");
}

// Covariance: a Dog-producer IS an Animal-producer (every Dog it returns is an Animal)
IReadRepository<Animal> repo = new DogRepository();
Animal a = repo.GetFirst();
Console.WriteLine(a.Name);          // Rex

// Contravariance: an Animal-consumer IS a Dog-consumer (it can handle any Dog)
IWriter<Dog> writer = new AnimalWriter();
writer.Write(new Dog { Name = "Buddy" });   // Writing Buddy

🎯 Quick Quiz

Question 1: What does the out keyword on a generic type parameter enable?

Question 2: Why is List<T> invariant?

Question 3: Which constraint requires T to be a non-nullable value type?

Summary

🎉 Key Takeaways

  • Variance governs whether Wrapper<Derived> is compatible with Wrapper<Base>.
  • Covariance (out): T is output-only; direction is preserved (IEnumerable<out T>, Func<out TResult>).
  • Contravariance (in): T is input-only; direction is reversed (IComparer<in T>, Action<in T>).
  • Invariance (default): T is used both ways (List<T>) — variance would break safety. Variance applies to interfaces and delegates only.
  • Advanced constraintsnotnull, unmanaged, Enum, and stacked base/interface/new() — unlock capabilities on T.

📚 Additional Resources

🚀 What's Next?

You've deepened your command of the type system. Next, we make APIs a joy to use. In Lesson 1.2: Extension Methods and Fluent APIs, you'll add methods to existing types and design chainable, readable interfaces.

🎉 Variance decoded!

You now understand one of C#'s most subtle features. Next: designing elegant APIs.