I’m sitting here reading through the “What’s New in C#9” blog post, and one of the listed additions to the language is Covariant return types. It sounds like an exciting feature, but what does that mean for my day-to-day development and API design choices? We’ll look at a few simple-to-understand examples and what it means for all .NET developers moving forward. Let’s get started!

What Does Covariance and Contravariance Mean?

I’ve heard the terms Covariance and Contravariance used in .NET to describe generic structures’ behaviors, but what do these terms mean?

Covariance allows for the assignment of a more-derived instance to a less-derived parameter or variable. For example, let’s take a look at the simplest use case. Since all .NET objects derive from object, we can assign an instance of List<string> to a variable of IEnumerable<object>.

IEnumerable<object> objects = new List<string>();

Note the lack of an explicit casting mechanism, which is not necessary due to the type argument’s derived nature, string inherits from object. In other words, the conversion is implicit.

Contravariance is the opposite behavior, allowing us to take an instance with a less derived type argument and assign it to a more derived type variable.

// less derived (object)
Action<object> actObject = obj => {};
// assigned to more derived (string) variable
Action<string> actString = actObject;  

A good mental model is to think of the flow of types from less to more (Covariance), and reversing from more to less (Contravariance) all happening implicitly. Both flows of types are crucial for generic data structures and generic methods. These mechanisms in .NET allow developers to support more implicit behaviors without explicitly adding interfaces or additional code.

What’s A Covariant Return Type?

The ability for covariant and contravariant generic type parameters has been present in C# for a while now, but C# 9 introduces the concept of Covariant return types, a feature that should make for more robust object-oriented programming approaches. What does it mean exactly? Remember the less-> more flow of types when thinking about Covariant behavior.

Specifically, permit the override of a method to declare a more derived return type than the method it overrides, and similarly to permit the override of a read-only property to declare a more derived type. –Microsoft

Let’s look at a code sample, and we’ll see what this means in practice. Note: .NET 5 SDK is required for these samples to compile and run.

First, let’s look at our base record types of Person and Identity. Both are abstract records, and the Id property is virtual, meaning any deriving record type can override it.

public abstract record Person
{
    public virtual Identity Id { get; }
}

public abstract record Identity
{
    public string Name { get; set; }
}

Let’s create a new record of Gamer and override the Id property with a more-derived Identity type. Remember, the covariant flow of data is from less->more.

public record Gamer : Person
{
    public Gamer(string name, string username)
    {
        Id = new GamerIdentity
        {
            Name = name,
            Username = username
        };
    }

    public override GamerIdentity Id { get; }
}

public record GamerIdentity : Identity
{
    public string Username { get; set; }
}

Notice, the Gamer record still satisfies the Person record interface, but it now returns a more-derived GamerIdentity record. How do we use this more-derived interface in our code?

var gamer = new Gamer(
    "Khalid",
    "leetKhalid"
);
// Id is GamerIdentity
var gamerId = gamer.Id;

It doesn’t look like much right now, but we’re able to use our gamer variable and access the more-derived GamerIdentity property. We also didn’t need to compromise the Person interface or use explicit casts from GamerIdentity to Identity. We can also assign our Gamer instance to a Person variable, after which, .NET will implicitly cast down our Id property to the less-derived Identity type.

Person person = gamer;
// Id becomes Identity
var id = person.Id; 

Looking at this example, we can see we get several advantages leaning towards covariant return types.

  1. Base types don’t have to understand their inheritance chain, especially if they have virtual methods and properties.
  2. Derived types can enhance and upgrade the data they return without invalidating the contract.
  3. Reduce the need for casting as conversions implicitly occur.

OSS authors can take advantage of covariant return types to add functionality for users upgrading to later versions, while not breaking the API for users unable to migrate to newer versions.

Complete C# 9 Sample

using System;
using System.IO;

var gamer = new Gamer(
    "Khalid",
    "leetKhalid"
);
// Id is GamerIdentity
var gamerId = gamer.Id;


Person person = gamer;
// Id becomes Identity
var id = person.Id; 

public abstract record Person
{
    public virtual Identity Id { get; }
}

public abstract record Identity
{
    public string Name { get; set; }
}

public record Gamer : Person
{
    public Gamer(string name, string username)
    {
        Id = new GamerIdentity
        {
            Name = name,
            Username = username
        };
    }

    public override GamerIdentity Id { get; }
}

public record GamerIdentity : Identity
{
    public string Username { get; set; }
}

Conclusion

Terminology can help us communicate faster within our technical communities, but sometimes it may not be evident what the words mean for the uninitiated. We’ve talked about how covariance is the idea of going from a less derived type to a more derived type when working with generic data structures. In C# 9, we can use covariant return types to allow for more flexible API contracts for derived classes and records. Additionally, the newest feature should help OSS authors enhance current libraries while keeping breaking changes to a minimum. C# 9 brings many great features, but covariant return types might be one of those subtly dramatic changes to the C# language.

References