Back to Blog
C#

C# Generic Variance: Covariance & Contravariance

c# generic variance: Understand C# generic variance: how out and in keywords enable covariance and contravariance in interfaces and delegates, with practical examples.

covariancecontravariancegeneric interfacesdelegatestype safety
Illustration of C# generic variance showing covariance and contravariance directions between base and derived types.

C# generic variance controls how type parameters can be substituted between derived and base types when using generic interfaces and delegates. Without variance, a method that accepts IEnumerable<object> cannot accept an IEnumerable<string>, even though every string is an object. The out and in keywords change that behavior.

Covariance: Assigning a Derived Type to a Base Type

Covariance allows you to use a more derived type than originally specified. The classic example is IEnumerable<T>, which is declared as IEnumerable<out T>. This means you can assign an IEnumerable<string> to a variable of type IEnumerable<object>:

IEnumerable<string> strings = new List<string> { "a", "b" }; IEnumerable<object> objects = strings; // Valid due to covariance

The out keyword in the interface declaration signals that T only appears in output positions—that is, the interface can return T but never accept it as a parameter. This guarantees that every operation you can perform on IEnumerable<object> is also safe on IEnumerable<string>. You can only read from the sequence, and reading a string as an object is always valid.

Contravariance: Accepting a Base Type Where a Derived Type Is Expected

Contravariance is the opposite direction. It lets you use a less derived type than originally specified. The in keyword marks a type parameter that only appears in input positions. A common example is Action<T>, declared as Action<in T>. This allows you to assign an Action<object> to an Action<string>:

Action<object> printObject = obj => Console.WriteLine(obj); Action<string> printString = printObject; // Valid due to contravariance

Why is this safe? An Action<string> expects to receive a string. An Action<object> can handle any object, including a string. So every time the Action<string> is invoked with a string, the underlying Action<object> can process it correctly. The in keyword ensures that T is never returned from the delegate or interface, so the consumer never receives a type it didn't expect.

Declaring Variance on Your Own Interfaces and Delegates

You can apply variance to your own generic interfaces and delegates using the same out and in keywords. For an interface, the rules are straightforward:

public interface IProducer<out T> { T Produce(); } public interface IConsumer<in T> { void Consume(T item); }

IProducer<T> can only use T as a return type or in output positions. IConsumer<T> can only use T as a parameter type. Attempting to use T in the wrong position causes a compile-time error. For example, adding a method void SetValue(T value) to IProducer<T> with out T fails because T appears as an input.

Delegates follow the same pattern. You can declare a delegate like public delegate T Factory<out T>(); to make it covariant, or public delegate void Handler<in T>(T item); for contravariance.

How the Compiler Enforces Variance Safety

The compiler enforces variance by checking the positions where type parameters appear. For out parameters, the type can only appear in output positions: return types, property getters, and delegate return types. For in parameters, the type can only appear in input positions: method parameters, property setters, and delegate parameters. This restriction prevents unsafe conversions that could break type safety at runtime.

Consider what would happen if IEnumerable<T> allowed T in input positions. You could add an item to a sequence that was originally typed as IEnumerable<string> but now holds an object. The compiler would not be able to guarantee that the added item is actually a string, leading to a potential InvalidCastException later. By restricting T to output positions, covariance remains safe.

The same logic applies to contravariance. If Action<T> allowed T as a return type, an Action<object> assigned to an Action<string> could return an object where the caller expects a string. The in keyword prevents this by only allowing T in parameters.

Common Mistakes and Misconceptions

One frequent mistake is assuming that all generic types support variance. In fact, variance is only supported for interfaces and delegates, not for classes. You cannot write class MyList<out T> because classes do not support variance. This is a deliberate design decision: classes can have both input and output members, making safe variance impossible in general.

Another misconception is that variance works with value types. It does not. Variance is only applicable to reference types. For example, IEnumerable<int> cannot be assigned to IEnumerable<object> because int is a value type and the conversion would require boxing, which is not covered by variance. The compiler rejects such assignments.

Arrays are a special case. In C#, arrays are covariant, meaning a string[] can be assigned to an object[]. However, this covariance is not type-safe. You can write object[] objects = new string[1]; objects[0] = 42; and the assignment throws an ArrayTypeMismatchException at runtime. This is a historical design choice that predates generic variance, and it's why you should prefer IEnumerable<T> over arrays when you need read-only access.

When to Use Variance in Your API Design

Variance is most useful when you design APIs that work with collections or delegates. For read-only operations, mark your interface with out. This allows callers to pass a more derived type than your method signature expects. For example, a method that accepts IEnumerable<object> can now accept IEnumerable<string> without requiring the caller to convert the collection.

For write-only operations, use in. This is common in event handling and command patterns where you want to accept a base type handler for a derived type event. The .NET framework uses this extensively in Action<T> and Func<TResult>.

When designing your own interfaces, ask yourself whether the type parameter appears only in input or output positions. If it appears in both, variance is not possible. In that case, you might split the interface into two separate interfaces—one for reading and one for writing—to gain variance where it matters.

Runtime Behavior and Type Safety Tradeoffs

Generic variance is a compile-time feature; it has no runtime overhead. The compiled IL is identical whether you use variance or not. The compiler inserts appropriate casts where needed, but these casts are safe because of the position restrictions. This means you get flexibility without sacrificing performance.

However, variance can affect type safety in subtle ways. Consider a covariant interface that returns T. If you assign an IProducer<Cat> to an IProducer<Animal>, you can call Produce() and get an Animal. That's fine. But if you later cast the result to Dog, you might get an InvalidCastException if the actual object is a Cat. Variance does not change the underlying runtime type; it only changes the compile-time view. You still need to be careful about downcasting.

Contravariance has a similar tradeoff. An IConsumer<Animal> assigned to an IConsumer<Cat> can accept any Cat, but it might also receive an Animal if you use it through the base reference. The implementation must handle all types it might receive, which is why in parameters are often more restrictive in practice.

When designing APIs, consider the maintainability impact. Variance can make your API more flexible, but it also adds complexity. If you don't need the flexibility, omitting out and in keeps the contract simpler. On the other hand, using variance where appropriate can reduce the need for casting and make your code more readable.

c# generic variance: Practical Usage and Code Examples | RYUSLOG DEV