Understanding Covariance and Contravariance of Generic Types in C#

by Zoran Horvat
May 31, 2022

In this article, we will examine what covariant and contravariant generic types are. We will explain what it means for a generic type to be variant, are and how do we use variance when constructing and consuming generic types. You can learn more about this topic from video course Collections and Generics in C# , published at Pluralsight .

Let us now explain what covariant and contravariant types are. Those terms have come to programming from physics, where we observe causes and effects. What happens if we change the scale of the cause? - what happens to the consequence? If shrinking the cause makes the consequence shrink as well, then those two are covariant. They are changing in the same direction. If scaling down the cause makes the effect scale up, become larger, then we are talking about contravariant cause and effect.

Variance in Programming

The same thinking from the physical systems is applied in programming when defining generic types. It all boils down to observing the object substitution principle. That is the fundamental principle of object-oriented programming. We can say that object-oriented programming is all about the substitution.

We can declare the base class and the derived class, a class that inherits from it. We can instantiate an object of the base class and assign it to a reference declared as the base type. That is obviously legal. Besides that, it is also legal to assign a derived object to the base reference.

class Base { }
class Derived : Base { }

Base x = new Base();
Base y = new Derived();

This is important because compiler needs to verify members at compile time. For example, if we had a method on the base class, and we wanted to call that method on the base reference referencing the base object, then everything will be all right. But we also want to invoke the same member on another object, the derived object, once again assigned to the base reference. Compiler will allow both invocations. Everything is alright in this piece of code, because base class is declaring the method, and base class reference is sufficient to grant access to that method.

class Base
{
  public void DoSomething() =>
    Console.WriteLine(
      $"Doing from {this.GetType().Name}");
}

class Derived : Base { }

Base x = new Base();
Base y = new Derived();

x.DoSomething();  // Prints: Doing from Base
y.DoSomething();  // Prints: Doing from Derived

However, what if there were a member on the derived class as well? Could we invoke that member on the derived object through a base class reference? No, we cannot, because compiler does not see the object - it can only make conclusions from the type of the reference. It is the reference to the base class in this code sample, and base class has no method which we are seeking. Therefore, this call is illegal.

class Base
{
  public void DoSomething() =>
    Console.WriteLine(
      $"Doing from {this.GetType().Name}");
}

class Derived : Base
{
  public void DoMore() =>
    Console.WriteLine(
      $"Doing more from {this.GetType().Name}");
}

Base x = new Base();
Base y = new Derived();

x.DoSomething();  // Prints: Doing from Base
y.DoSomething();  // Prints: Doing from Derived

Derived z = new Derived();
z.DoMore();       // Prints: Doing more from Derived
z.DoSomething();  // Prints: Doing from Derived

The code above demonstrates that we must assign the derived object to a reference of the derived type before attempting to access the method defined on the derived class. Of course, we can also access the base member through the derived reference - that is perfectly legal as well.

This completes the four possibilities that exist when we are using a reference to the base type or a reference to the derived type, and members defined in both classes. This is how object substitution principle works on regular types, and that is where we come to variance.

What happens when we introduce generic types? Those four possibilities we had with common classes will become eight variants when the classes attain one generic type parameter.

Augmenting Classes with the Generic Type Parameter

Variance can be declared on interfaces and delegate types in C#. Variance is augmenting the object substitution principle to apply to generic types following the same principles as in non-generic classes. In object-oriented programming, it is all about object substitution, and it is so when speaking about generic types, too.

When speaking of interfaces - generic or not - they can either produce or consume objects. That will be very important when speaking about variance.

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

interface IConsumer<in T>
{
  void Consume(T obj);
}

The producing interface is returning an object of the parameter type T. We denote that in C# with the keyword out. This out is indicating the covariant type and you will see what covariant means when we start assigning references to these interfaces.

On the other hand, if we had an interface which consumes, which accepts, the object of parameter type T through its member, then we would mark the argument with the in keyword. The argument is coming into the interface object, and we say that it is the contravariant type.

What is the consequence of having this producer above as covariant and this consumer as contravariant interfaces?

For one thing if we had a producer of base type, what can we do with it? This object is obviously returning references to the base type. That is what its declaration says. Could we assign that result to the derived reference? No, because this interface, the producer of base type, is returning something that is declared as the base type and we cannot assign that to the derived reference. That would violate the object substitution principle, so this assignment is not legal in C#.

IProducer<Base> prodOfBase = null!;
Base a = prodOfBase.Produce();
// No: Derived b = prodOfBase.Produce();

IProducer<Derived> prodOfDerived = null!;
Derived b = prodOfDerived.Produce();
Base c = prodOfDerived.Produce();

What is legal in C# though is if we started from the other end - from a producer of the derived type. We can obviously assign the product of that interface to the derived reference: Yes, that is the same thing, this interface is producing a derived reference. But we can also assign that result to the base reference. That is object substitution principle applied in a legal way. It turns out that the producer of the derived class behaves like a derived interface from the producer of the base class.

The producing interfaces are behaving covariantly. They are moving in the same direction. You derive a class, a generic parameter class, one from another, and generic interfaces of these two classes behave as if they were derived from one another in the same direction.

But what happens if we started using the consuming interface? It is accepting objects, so the consumer of base can obviously receive a concrete object of the base type - that is the same thing, so we can assign the new base object to the method argument which accepts a base reference.

IConsumer<Base> consOfBase = null!;
consOfBase.Consume(new Base());
consOfBase.Consume(new Derived());

IConsumer<Derived> consOfDerived = null!;
consOfDerived.Consume(new Derived());
// No: consOfDerived.Consume(new Base());

But what if we passed the derived object again? This is fine because we can assign the derived object to the method argument declared as a reference to the base type. And what if we started from the other end - from the consumer of the derived type?

Obviously, we can pass the derived object in. That is fine. But we cannot pass the base object in, because base object cannot be assigned to the method argument excepting derived. That is how the method argument is declared, so the last invocation above would violate the object substitution principle.

We conclude that consuming generic interfaces are behaving contravariantly. If we applied the derived class as the generic type parameter, then we would construct a base interface of the interface with the base class as the type parameter. These two interfaces are deriving in direction opposite to the way their type parameters are deriving.

Assignment Rules with Generic Types

Let us now investigate what would happen if we tried to assign these interface references one to another.

Can we assign a producer of base type to the reference of the same type? Yes, of course, that is the same type. But can we assign a producer of derived class to the reference which is a producer of base? Yes, because the right-hand side would produce derived objects which are assignable to base objects, so this assignment is perfectly legal.

IProducer<Base> p = prodOfBase;            // IProducer<Base>
IProducer<Base> q = prodOfDerived;         // IProducer<Derived>
IProducer<Derived> r = prodOfDerived;      // IProducer<Derived>
// No: IProducer<Derived> s = prodOfBase;  // IProducer<Base>

We can also assign the producer of derived instance to the producer of derived reference. Of course, that is the same type. But we cannot do it the other way around - the producer of base type is the supertype of the producer of derived, and the last assignment is illegal when observing the object substitution principle and assignment rules in C#.

The situation will be quite different with consuming interfaces. What would happen if we had a reference to the consumer of the derived class? We could assign the consumer of base instance, because consumer of base is expecting the base object and we are free to pass the derived object in. That is what makes it contravariant. This assignment is perfectly legal, and it proves that consuming interfaces are behaving contravariantly.

IConsumer<Derived> t = consOfDerived;      // IConsumer<Derived>
IConsumer<Derived> u = consOfBase;         // IConsumer<Base>
IConsumer<Base> v = consOfBase;            // IConsumer<Base>
// No: IConsumer<Base> w = consOfDerived;  // IConsumer<Derived>

Of course, we can assign the consumer of base instance to the consumer of base reference. But we cannot assign the consumer of derived object to the consumer of base reference, because that would mean that somebody could try to pass the base object into a method expecting a derived class reference and that would fail - such an assignment is not legal, and that code would not compile.

The code above is showing the entire picture of what can and what cannot be assigned, given the two variant interfaces: One covariant, with the out keyword, and one contravariant, with the in keyword.

If you had an interface which is both receiving and returning objects of type T then you cannot specify any variance on that interface. You can specify neither in nor out, because the only type that would satisfy both ends of the equation would be the type T itself. No base and no derived type of it would suffice, and such interface is invariant.

Conclusion

This is the whole story about interface variance. It is very simple indeed when observed from the ground of enforcing the object substitution principle. Again, if you want to learn more, there is the whole video course with a lot of demos, generics all the way through, titled Collections and Generics in C# . If you want to learn practical generics watch that course.


If you wish to learn more, please watch my latest video courses

About

Zoran Horvat

Zoran Horvat is the Principal Consultant at Coding Helmet, speaker and author of 100+ articles, and independent trainer on .NET technology stack. He can often be found speaking at conferences and user groups, promoting object-oriented and functional development style and clean coding practices and techniques that improve longevity of complex business applications.

  1. Pluralsight
  2. Udemy
  3. Twitter
  4. YouTube
  5. LinkedIn
  6. GitHub