Back to Blog
C#

Understanding C# Covariance in Generics and Interfaces

c# covariance: Learn how C# covariance works in arrays, generic interfaces, and delegates, where it is allowed, and how to avoid runtime exceptions.

C# GenericsCovarianceContravarianceType SafetyInterfaces
Diagram showing assignment of IEnumerable<string> to IEnumerable<object> with a checkmark for safe covariance.

In C#, covariance lets you use a more derived type where a less derived type is expected. It appears in arrays, generic interfaces, and delegates. Understanding where C# covariance applies—and where it does not—prevents runtime exceptions and keeps your generic code type-safe.

The Core Idea Behind C# Covariance

Covariance is about assignment compatibility. When a type T can be implicitly converted to a base type U, a covariant relationship allows a container of T to be treated as a container of U. For example, an IEnumerable<string> can be assigned to an IEnumerable<object> because string derives from object. This works only when the generic type parameter is used in a read-only (output) position. The out keyword in a generic interface marks a type parameter as covariant.

IEnumerable<string> strings = new List<string> { "one", "two" }; IEnumerable<object> objects = strings; // Covariance at work

Without covariance, such an assignment would cause a compile-time error. The compiler enforces that the type parameter is never used as an input parameter, ensuring that you cannot accidentally insert an object into a collection that is actually a List<string>.

Array Covariance and Its Runtime Trap

Arrays are covariant in C#. A string[] can be assigned to an object[] without any explicit conversion. This has been part of the language since early versions and mirrors Java's behavior. However, array covariance is not type-safe at runtime because arrays are mutable. If you attempt to store an object into what is actually a string[], you get an ArrayTypeMismatchException.

string[] stringArray = new string[3]; object[] objectArray = stringArray; // Compiles objectArray[0] = 42; // Throws ArrayTypeMismatchException

The assignment compiles because int is an object, but the runtime knows the array is really a string[] and rejects the write. This is a classic example of why covariance must be used with care. For read-only access, array covariance is convenient, but any write operation is a potential failure point.

Covariance in Generic Interfaces

Generic interfaces in .NET use the out keyword to declare a type parameter as covariant. The most common examples are IEnumerable<out T>, IReadOnlyList<out T>, and IReadOnlyCollection<out T>. These interfaces only expose methods that return T, never methods that accept T as an input. This makes them safe for covariance.

IEnumerable<string> strings = GetStrings(); IEnumerable<object> objects = strings; // Works because IEnumerable<out T>

When you design your own generic interface, you can apply the out keyword if the type parameter appears only in return types and property getters. This allows consumers to use your interface with a base type, increasing flexibility without sacrificing type safety.

public interface IRepository<out T> { T GetById(int id); IEnumerable<T> GetAll(); }

Here, T is only used as a return type, so the interface is covariant. A IRepository<Customer> can be assigned to IRepository<Person> if Customer derives from Person. This is useful in layered architectures where you want to expose a read-only view of a repository.

Contravariance: The In Keyword

Contravariance is the opposite direction: you can use a less derived type where a more derived type is expected. It applies to input parameters. The in keyword marks a type parameter as contravariant. The canonical example is IComparer<in T>. Because IComparer<T> only consumes T in its Compare method, an IComparer<object> can be used to compare strings.

IComparer<object> objectComparer = new ObjectComparer(); IComparer<string> stringComparer = objectComparer; // Contravariance

Contravariance is essential for generic delegates like Action<in T>. An Action<object> can be used where an Action<string> is expected because the delegate can accept any object, including a string. This is safe because the caller will always pass a string, which is an object.

Variance in Delegates

Delegates support both covariance and contravariance. The Func<out TResult> delegate is covariant in its return type, while Action<in T> is contravariant in its input type. This means you can assign a method that returns a string to a Func<object> variable, or a method that accepts an object to an Action<string> variable.

Func<string> getString = () => "hello"; Func<object> getObject = getString; // Covariance Action<object> printObject = obj => Console.WriteLine(obj); Action<string> printString = printObject; // Contravariance

These assignments are compile-time safe because the delegate's signature is checked against the method's actual parameters and return type. Variance in delegates is a powerful tool for building flexible event systems or command patterns without forcing explicit wrappers.

Where Covariance Is Not Allowed

Covariance does not apply to classes, mutable types, or value types. A class like List<T> cannot be covariant because it has both read and write methods. Value types, such as int and struct, do not support variance because they are not reference types and inheritance does not apply. Additionally, a type parameter cannot be marked out if it appears in a method parameter, even if that parameter is a delegate or another generic type that is itself covariant.

// This will not compile: public interface IInvalid<out T> { void Set(T value); // T is used as input }

The compiler enforces these restrictions to prevent unsafe casts that would break type safety. When you see a compile-time error about variance, it is usually because the type parameter is used in a position that contradicts the declared variance.

Practical Usage and Design Considerations

When designing APIs, use covariance to provide read-only access to collections and data sources. This lets callers work with base types without forcing them to cast or create new collections. For example, a method that returns IEnumerable<Customer> can be assigned to IEnumerable<Person>, which is useful when you have a list of customers and need to pass them to a method that expects a sequence of people.

Contravariance is valuable for comparers, predicates, and event handlers. An IComparer<object> can be reused for any derived type, reducing the number of comparer implementations. Similarly, an Action<object> can handle events for multiple derived types, simplifying event wiring.

One common mistake is assuming that variance applies to all generic interfaces. It only applies when the interface is explicitly marked with out or in. The .NET base class library has many covariant interfaces, but not all. For instance, IList<T> is not covariant because it allows writes. If you need covariance, choose IReadOnlyList<T> or IEnumerable<T> instead.

Runtime Behavior and Type Safety

Variance is a compile-time feature. The runtime does not treat a covariant assignment differently from a normal reference conversion. There is no performance overhead, and no extra type checks are inserted. The safety is enforced by the compiler, which prevents you from writing code that would cause a type mismatch. However, when you use array covariance, the runtime must perform a type check on every write, which is why it can throw an exception. Generic variance avoids this because the type parameter is fixed at compile time.

Understanding this distinction helps you choose between arrays and generic collections. If you need covariance and intend to read only, a covariant interface is safer than an array. If you need to write, you cannot use covariance at all. In that case, you should design your method signatures to accept the exact type or use a non-generic interface like IList with explicit casting.

c# covariance: Practical Usage and Code Examples | RYUSLOG DEV