Back to Blog
C#

C# Generic Covariance: How It Works and Where to Use It

Understand C# generic covariance with practical examples, including how the out keyword enables safe type substitution in interfaces and delegates.

C#GenericsCovarianceType SafetyIEnumerableDelegates
Diagram showing a covariant generic interface allowing assignment from IEnumerable<string> to IEnumerable<object>

C# generic covariance lets a generic interface or delegate constructed with a more derived type argument be treated as one constructed with a less derived type argument, as long as the type parameter is marked with the out keyword. The most familiar case is IEnumerable<string> being assignable to IEnumerable<object> because string derives from object. This behavior is a compile-time feature that preserves type safety without any runtime conversion.

The Core Idea of Covariance in Generic Types

Covariance in generics means that if Derived is a subtype of Base, then SomeGeneric<Derived> is treated as a subtype of SomeGeneric<Base>. In C#, this is only allowed when the type parameter appears in output positions, such as return types, and never in input positions like method parameters. The compiler enforces this restriction to prevent unsafe calls that could break type safety.

Consider a simple interface that exposes a read-only collection. Because you can only get items out of it, not put them in, it is safe to treat a collection of string as a collection of object. The out keyword makes this relationship explicit and verifiable at compile time.

IEnumerable<string> strings = new List<string>(); IEnumerable<object> objects = strings; // Allowed because IEnumerable<out T>

Without covariance, that assignment would fail. The runtime does not need to perform any cast or wrapper; the reference is simply assigned because the type system guarantees that every string is an object.

How the out Keyword Declares Covariance

When you define a generic interface or delegate, you can mark a type parameter with out to declare that it is covariant. This tells the compiler that the type parameter will only be used in output positions. For example:

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

Here, T appears only as a return type. The compiler allows IProducer<Derived> to be assigned to IProducer<Base>. If you try to use T as a parameter, the compiler rejects it:

public interface IProducer<out T> { void Consume(T item); // Compiler error: Invalid variance }

The error occurs because a method that accepts T would allow a caller to pass a Base where a Derived is expected, breaking type safety. The out keyword is a contract that the implementer must honor; the compiler enforces it at the point of declaration.

Covariance in Built-in Interfaces and Delegates

The .NET base class library uses covariance in several widely used types. IEnumerable<out T> is the most common example. Others include IReadOnlyList<out T>, IReadOnlyCollection<out T>, and IGrouping<out TKey, out TElement>. Delegates such as Func<out TResult> are also covariant on their return type.

This means you can write code like:

Func<string> getString = () => "hello"; Func<object> getObject = getString; // Allowed

Similarly, you can pass an IEnumerable<string> to a method that expects IEnumerable<object>:

void PrintAll(IEnumerable<object> items) { /* ... */ } IEnumerable<string> names = new[] { "Ada", "Grace" }; PrintAll(names); // Works because IEnumerable<out T>

This is a practical benefit when you have a method that only reads from a collection. You no longer need to create a new List<object> or use Cast<object>(); the assignment just works.

Defining Your Own Covariant Interface

You can design your own covariant interfaces when the type parameter is only returned, never consumed. A typical scenario is a factory or a read-only repository. For example:

public interface IRepository<out T> { T GetById(int id); IEnumerable<T> GetAll(); } public class InMemoryRepository<T> : IRepository<T> { public T GetById(int id) => default; public IEnumerable<T> GetAll() => Enumerable.Empty<T>(); }

Because T appears only in return positions, you can use IRepository<Derived> where IRepository<Base> is expected. This is useful when you want to expose a read-only view of a repository without forcing callers to know the concrete type.

IRepository<Derived> derivedRepo = new InMemoryRepository<Derived>(); IRepository<Base> baseRepo = derivedRepo; // Allowed

The same rule applies to delegates. If you define a delegate with an out type parameter, it becomes covariant:

public delegate T Factory<out T>(); Factory<string> stringFactory = () => "x"; Factory<object> objectFactory = stringFactory; // Allowed

Keep in mind that covariant conversions are only possible between reference types. Value types like int do not participate in covariance because they do not have inheritance relationships. IEnumerable<int> cannot be assigned to IEnumerable<object> even though int can be boxed to object; the generic variance rules require an implicit reference conversion, not a boxing conversion.

What Covariance Does Not Allow

Covariance does not apply to classes or structs. You cannot declare a covariant class:

public class Wrapper<out T> { } // Compiler error

The C# language supports the out modifier for generic type parameters only on interfaces and delegates. Classes and structs are invariant, partly because their fields and methods can both consume and produce the type parameter.

Covariance also does not allow mutation. Even with a covariant interface, you cannot add a method that accepts T. The compiler will reject it. This is why IList<T> is not covariant; it has Add(T) and indexer setters. The read-only interfaces are covariant precisely because they omit those members.

Another limitation is that covariance works through reference conversions between type arguments. For example, IEnumerable<KeyValuePair<string, string>> is not assignable to IEnumerable<KeyValuePair<object, object>> because KeyValuePair<TKey, TValue> is a struct and no implicit reference conversion exists between the constructed value types. Value type arguments, therefore, do not participate in generic variance.

Runtime Behavior and Type Safety Considerations

Covariance is a compile-time feature; it has no runtime cost. The assignment is a simple reference copy. There is no casting, no wrapper object, and no performance penalty. The type safety is guaranteed by the compiler, so you do not need runtime checks.

However, covariance changes only the static view of a reference. The underlying object remains the same concrete collection; for example, a List<string> assigned to IEnumerable<object> is still a List<string> at runtime. This matters when you inspect the runtime type or use reflection, but it does not affect safe reads through the covariant interface.

Another consideration is that covariance can make APIs more flexible but also less explicit. When a method accepts IEnumerable<object>, callers may pass collections of any reference type. This is often desirable, but it can hide the actual element type from the method body. If the method needs to know the specific type, you may need to use generics instead of covariance.

For maintainability, prefer covariant interfaces when you are designing read-only abstractions. This gives consumers more flexibility without sacrificing type safety. But do not force covariance onto an interface that needs input methods; that will only lead to compiler errors and awkward workarounds.

When to Prefer Covariant Design

Use a covariant interface when the type parameter appears only in output positions and you want to enable broader reuse. Common examples are read-only collections, factories, and providers. If you need to both read and write, you cannot use covariance; instead, consider splitting the interface into a read-only covariant part and a separate mutating part.

For instance, you might have IReadOnlyRepository<out T> for queries and IWriteRepository<T> for updates. This allows methods that only read to accept any repository of a derived type, while write methods remain type-safe and non-variant.

When you are consuming a covariant interface, remember that you can only read from it. If you need to add items, you must use a non-covariant interface or a concrete type. This is not a limitation but a safety guarantee.

C# Generic Covariance: Practical Examples and Design Guidance | RYUSLOG DEV