Mert Özen Further With Every Line
Backend C# Collections and Generics — #09

Generic Variance in C#: Covariance and Contravariance (in and out)

Mert Özen Nov 7, 2026 10 min 3 views
Generic Variance in C#: Covariance and Contravariance (in and out)

Why can't a List<EBook> be a List<Book> when it can be an IEnumerable<Book>? The hole in array covariance, covariance with out, contravariance with in, variance in delegates, the limits of the rules and the cast trap CS0266 suggests.

Version note: The code in this post uses C# 10 and the .NET 6 APIs; it was built and run with the .NET 10 SDK.

Guess First

The Bookstore has a hierarchy of books: EBook and AudioBook are both a Book. We have a list of e-books, and we assign it to variables of four different types. Which of these four lines compile?

var ebooks = new List<EBook> { new("978-1617294532", "C# in Depth", 39.99m, 12.4) };

IEnumerable<Book> a = ebooks;
IReadOnlyList<Book> b = ebooks;
IList<Book> c = ebooks;
List<Book> d = ebooks;

record Book(string Isbn, string Title, decimal Price);
record EBook(string Isbn, string Title, decimal Price, double FileSizeMb) : Book(Isbn, Title, Price);
record AudioBook(string Isbn, string Title, decimal Price, int Minutes) : Book(Isbn, Title, Price);

The Compiler's Answer

The first two lines compile, the last two do not:

Program.cs(5,17): error CS0266: Cannot implicitly convert type 'System.Collections.Generic.List<EBook>' to 'System.Collections.Generic.IList<Book>'. An explicit conversion exists (are you missing a cast?)
Program.cs(6,16): error CS0029: Cannot implicitly convert type 'System.Collections.Generic.List<EBook>' to 'System.Collections.Generic.List<Book>'

Since every EBook is a Book, it feels like a "list of e-books" should also be a "list of books". The compiler partly agrees: it lets you see the e-books as IEnumerable<Book> or IReadOnlyList<Book>, but not as IList<Book> or List<Book>. Whether a generic type carries the inheritance relationship between its type arguments is called variance. If it carries it, that is covariance; if it carries it in the opposite direction, contravariance; if it does not carry it at all, invariance. This whole post is about how the compiler makes that decision.

Let's continue with the two lines that compile:

var ebooks = new List<EBook> { new("978-1617294532", "C# in Depth", 39.99m, 12.4) };

IEnumerable<Book> books = ebooks;
IReadOnlyList<Book> shelf = ebooks;

Console.WriteLine(shelf[0].Title);
Console.WriteLine(ReferenceEquals(books, ebooks));

object unknown = ebooks;
Console.WriteLine(unknown is IEnumerable<Book>);

record Book(string Isbn, string Title, decimal Price);
record EBook(string Isbn, string Title, decimal Price, double FileSizeMb) : Book(Isbn, Title, Price);
record AudioBook(string Isbn, string Title, decimal Price, int Minutes) : Book(Isbn, Title, Price);
C# in Depth
True
True

It matters that ReferenceEquals returns true: a variance conversion does not create a new collection, copy anything or wrap anything. You look at the same object through a different interface type. The is check on the last line shows that the rule holds not only in the compiler but also at run time.

The Old Hole in Arrays

Before generics, C# already had a kind of covariance, and it is still here: arrays. You can assign an EBook[] to a Book[]. The trouble starts when you try to write through the Book[]:

Book[] shelf = new EBook[2];
shelf[0] = new EBook("978-1617294532", "C# in Depth", 39.99m, 12.4);

try
{
    shelf[1] = new AudioBook("978-1491950357", "Building Microservices", 29.99m, 840);
}
catch (ArrayTypeMismatchException ex)
{
    Console.WriteLine($"{ex.GetType().Name}: {ex.Message}");
}

record Book(string Isbn, string Title, decimal Price);
record EBook(string Isbn, string Title, decimal Price, double FileSizeMb) : Book(Isbn, Title, Price);
record AudioBook(string Isbn, string Title, decimal Price, int Minutes) : Book(Isbn, Title, Price);
ArrayTypeMismatchException: Attempted to access an element as a type incompatible with the array.

As far as the compiler is concerned, shelf is a Book[] and AudioBook is a Book; the assignment looks flawless. But the object behind it is an EBook[], and an audiobook cannot go in. The runtime checks this on array writes and throws on a mismatch. That check is not free either: assignments to arrays of reference types usually go through this type check. Array covariance has been in .NET since the first version, for compatibility with Java-like languages, and today most people consider it a design mistake. Generic collections were designed with a stricter rule so they would not repeat the same mistake.

Why List<T> Does Not Bend

Imagine for a moment that we could assign a List<EBook> to a List<Book>. List<Book> has an Add(Book) method, and since AudioBook is a Book, you could pass an audiobook to Add. An audiobook would then be inside the e-book list, and code that later reads the list as List<EBook> would run into an object without a FileSizeMb. The error the runtime catches for arrays never even starts with generic types, because the compiler stops it.

So why is IEnumerable<T> allowed? Because you cannot put anything into it, you can only take things out. Everything that comes out of a sequence of e-books is an e-book, and therefore a book. Nobody reading it is lied to. That is where the rule comes from: a type that only gives T out can be covariant, a type that only takes T in can be contravariant, and a type that does both must stay invariant. IList<T> returns T from its indexer and also takes T in Add(T), which makes it invariant.

out: Covariance

In C#, when you mark a type parameter of an interface or delegate with out, you declare it covariant. In .NET, IEnumerable<out T>, IEnumerator<out T>, IReadOnlyCollection<out T>, IReadOnlyList<out T> and Func<out TResult> are defined this way. That is what is behind the two lines that compiled in the guessing question. You can use the same marker on your own interfaces, but the compiler checks whether you keep your promise. If you give a shelf the ability both to take books and to put books, and still say out:

interface IShelf<out T>
{
    int Count { get; }
    T GetAt(int index);
    void Put(T item);
}

the compiler rejects Put:

Program.cs(5,14): error CS1961: Invalid variance: The type parameter 'T' must be contravariantly valid on 'IShelf<T>.Put(T)'. 'T' is covariant.

The message states exactly the logic above: in Put, T is in an input position, but you declared T covariant. The fix is to split the read and write contracts. The interface defines only reading, and the ability to write stays in the concrete class:

var ebookShelf = new Shelf<EBook>(new[]
{
    new EBook("978-1617294532", "C# in Depth", 39.99m, 12.4),
    new EBook("978-1492056812", "Programming C# 10", 49.99m, 18.1),
});

IShelf<Book> anyShelf = ebookShelf;
PrintTitles(anyShelf);

static void PrintTitles(IShelf<Book> shelf)
{
    for (int i = 0; i < shelf.Count; i++)
        Console.WriteLine(shelf.GetAt(i).Title);
}

interface IShelf<out T>
{
    int Count { get; }
    T GetAt(int index);
}

class Shelf<T> : IShelf<T>
{
    private readonly List<T> _items;

    public Shelf(IEnumerable<T> items) => _items = new List<T>(items);

    public int Count => _items.Count;
    public T GetAt(int index) => _items[index];
    public void Put(T item) => _items.Add(item);
}

record Book(string Isbn, string Title, decimal Price);
record EBook(string Isbn, string Title, decimal Price, double FileSizeMb) : Book(Isbn, Title, Price);
record AudioBook(string Isbn, string Title, decimal Price, int Minutes) : Book(Isbn, Title, Price);
C# in Depth
Programming C# 10

PrintTitles accepts any bookshelf, and now an e-book shelf can go through that door too. .NET's own design is the same: List<T> is mutable and invariant, while its read side, IReadOnlyList<out T>, is covariant. When the earlier posts said "hand out IReadOnlyList<T>", you were getting one more benefit: the caller can also use it as a more general type.

in: Contravariance

Think of the opposite direction. Can a comparer that compares books by price also compare e-books? Of course; every e-book has a price. For types that only take T as input, the conversion is safe in the reverse direction: something that can work with a Book can also work with an EBook. You mark this with in. IComparer<in T>, IEqualityComparer<in T>, IComparable<in T> and Action<in T> are defined this way:

using System.Globalization;

CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;

var ebooks = new List<EBook>
{
    new("978-1492056812", "Programming C# 10", 49.99m, 18.1),
    new("978-1617294532", "C# in Depth", 39.99m, 12.4),
    new("978-1098107703", "Head First C#", 44.99m, 52.0),
};

IComparer<Book> byPrice = Comparer<Book>.Create((x, y) => x.Price.CompareTo(y.Price));
ebooks.Sort(byPrice);

Action<Book> print = book => Console.WriteLine($"{book.Price} {book.Title}");
Action<EBook> printEBook = print;
ebooks.ForEach(printEBook);

IValidator<EBook> validator = new PriceValidator();
Console.WriteLine(validator.IsValid(ebooks[0]));

interface IValidator<in T>
{
    bool IsValid(T item);
}

class PriceValidator : IValidator<Book>
{
    public bool IsValid(Book item) => item.Price > 0;
}

record Book(string Isbn, string Title, decimal Price);
record EBook(string Isbn, string Title, decimal Price, double FileSizeMb) : Book(Isbn, Title, Price);
record AudioBook(string Isbn, string Title, decimal Price, int Minutes) : Book(Isbn, Title, Price);
39.99 C# in Depth
44.99 Head First C#
49.99 Programming C# 10
True

ebooks.Sort expects an IComparer<EBook>, and we gave it an IComparer<Book>. print, of type Action<Book>, was assigned to an Action<EBook> variable without trouble. We did the same with our own IValidator<in T>: a book validator can stand in for an e-book validator. The practical payoff: you write general rules once, for the base type, and you don't have to produce a separate comparer, validator or handler for every subtype.

The easy way to remember it is the names themselves: an out type parameter only goes out, an in type parameter only comes in. A type that produces something (a producer) is covariant, a type that consumes something (a consumer) is contravariant.

09-variance-directions-en.png

Variance in Delegates

Like interfaces, delegates can have variant type parameters. Func<in T, out TResult> shows both together: contravariant on the parameter side, covariant on the return side:

Func<EBook> createEBook = () => new EBook("978-1617294532", "C# in Depth", 39.99m, 12.4);
Func<Book> createBook = createEBook;
Console.WriteLine(createBook().Title);

Func<Book, string> describe = book => $"{book.Title} ({book.Isbn})";
Func<EBook, object> describeEBook = describe;
Console.WriteLine(describeEBook(new EBook("978-1492056812", "Programming C# 10", 49.99m, 18.1)));

record Book(string Isbn, string Title, decimal Price);
record EBook(string Isbn, string Title, decimal Price, double FileSizeMb) : Book(Isbn, Title, Price);
record AudioBook(string Isbn, string Title, decimal Price, int Minutes) : Book(Isbn, Title, Price);
C# in Depth
Programming C# 10 (978-1492056812)

A function that produces e-books can stand in for a function that produces books. A function that takes a book and returns a string can stand in for a function that takes an e-book and returns an object; we bent in both directions at once. This is why event handlers, callbacks and the lambdas you pass to LINQ operators can be written against base types. Delegates get their own coverage in the advanced posts of the series.

The Limits of the Rules

Variance exists only on the type parameters of interfaces and delegates. If you try writing out on a class:

class Box<out T>
{
}
Program.cs(1,11): error CS1960: Invalid variance modifier. Only interface and delegate type parameters can be specified as variant.

Classes and structs are always invariant. So when you want to design a variant API, you pull the shared part out into an interface, just like we did with IShelf<out T>.

The second limit is that variance works only between reference types. An int can be converted to object, but that conversion requires boxing, which means creating a new object:

List<int> stocks = new() { 3, 0, 5 };
IEnumerable<object> boxes = stocks;
Program.cs(2,29): error CS0266: Cannot implicitly convert type 'System.Collections.Generic.List<int>' to 'System.Collections.Generic.IEnumerable<object>'. An explicit conversion exists (are you missing a cast?)

A variance conversion is looking at the same object with different eyes; producing a new collection by boxing every int element falls outside that definition. If you need such a conversion, you produce a new sequence explicitly with LINQ's Cast<object>() or Select operator. For the same reason, IEnumerable<EBook> → IEnumerable<Book> works, but it would not if EBook were a struct.

Common Pitfalls

Trusting the "An explicit conversion exists" message. The CS0266 in the guessing question suggests a cast. If you write the cast, the code compiles, but since List<EBook> does not really implement IList<Book>, it blows up at run time:

var ebooks = new List<EBook> { new("978-1617294532", "C# in Depth", 39.99m, 12.4) };

try
{
    IList<Book> books = (IList<Book>)ebooks;
}
catch (InvalidCastException ex)
{
    Console.WriteLine(ex.GetType().Name);
}

record Book(string Isbn, string Title, decimal Price);
record EBook(string Isbn, string Title, decimal Price, double FileSizeMb) : Book(Isbn, Title, Price);
InvalidCastException

The compiler allows this cast because, in theory, there could be a class that derives from List<EBook> and also implements IList<Book>. The right fix is to ask for the covariant interface that expresses what you need, or to build a new list explicitly with ebooks.Cast<Book>().ToList().

Thinking every read interface is covariant. IReadOnlyDictionary<TKey, TValue> is read-only but invariant; the out TValue parameter of TryGetValue and the methods that take a key as input do not allow it:

var byIsbn = new Dictionary<string, EBook>();
IReadOnlyDictionary<string, Book> catalog = byIsbn;

record Book(string Isbn, string Title, decimal Price);
record EBook(string Isbn, string Title, decimal Price, double FileSizeMb) : Book(Isbn, Title, Price);
Program.cs(2,45): error CS0266: Cannot implicitly convert type 'System.Collections.Generic.Dictionary<string, EBook>' to 'System.Collections.Generic.IReadOnlyDictionary<string, Book>'. An explicit conversion exists (are you missing a cast?)

In a case like this, look at what the method really needs. If it only walks the books, you can pass byIsbn.Values as an IEnumerable<Book>.

Thinking array covariance is safe. You can pass an EBook[] to a method that takes a Book[] parameter; if the method writes into the array, you get an ArrayTypeMismatchException at run time. If you only read, make the parameter IReadOnlyList<Book>.

Expecting variance with value types. IEnumerable<int> is not an IEnumerable<object>, and IEnumerable<decimal> is not an IEnumerable<IComparable>.

A Small Experiment

★☆☆ Try assigning a List<Book> to a variable of type IEnumerable<AudioBook>. What does the compiler say, and why?

★★☆ Add a T? FindByIsbn(string isbn) method to IShelf<out T>. Does it compile? Then try adding bool Contains(T item) and read the error message.

★★★ Write an interface named IHandler<in T> with a void Handle(T item) method. Create a logging handler for Book and a download-link handler for EBook, and collect both in a List<IHandler<EBook>>.

With this post, the core posts of the series, built on C# 10 and .NET 6, are complete: generics, constraints, the basic collections, interfaces, immutable types and variance. The next two posts are version posts. In the first one we look at what came with C# 11–12 and .NET 7–8: generic math with static abstract members, Frozen collections, and collection expressions written as [1, 2, 3]. For each topic we will first see how we wrote it in C# 10, then what the new version changes.