Covariance, contravariance and a little bit of TypeScript

Michał Skoczylas
8 min readFeb 24, 2019

--

How to explain the obvious

Many programmers appreciate different possibilities of static code checking. We can use various utilities that help us maintain correctness, readability, and specific style guide. Speaking of correctness, which lies at the root of reliable code, static type checking is one of the best approaches to provide quality across large code bases.

Recently, my primary interest gathers around web development. Thus I have a great appreciation for languages like TypeScript or Flow, which are leading the whole JavaScript world toward a bright future, where our logic is safe from at least some category of bugs.
Although the idea of static typing may seem quite simple, from time to time, we encounter a question or uncertainty that is not particularly hard to understand, but somewhat complicated to present in simple words with proper terminology. Today’s question is this “ Why can we pass an argument that has more properties into a function that requires less and why the logic reverses when that argument is a function itself?”.

Subsets, subclasses, subtypes

We shall begin with an intuitive explanation which in my mind merges the worlds of set theory, object-oriented programming, and animal genetics. We’ll start with the easiest one — mathematics.
We quickly grasp the idea of superset and subset, because superset contains subset and subset is contained by superset. So what is the intuition here regarding inclusion? We can think of those sets as containing some elements, and we can state that every element that belongs to the subset, belongs to the superset as well, in contrary to some elements which may belong only to the superset. Do you remember a circle in a circle picture from school that explained the idea, the dependency between superset and subset? The inner circle is a subset, the outer superset.

Superset and subset

The same analogy comes with superclass and subclass, and just like before we can draw a circle in a circle picture. Though, what should we put in those circles? What should be our elements? To maintain the simplistic approach, we should count instances of those classes and fill circles with them. When subclass extends a superclass, we can say that every instance of the subclass is in some way an instance of the superclass and even though subclass has more properties, these properties are not the ones that count here, no, in this case, we count instances of given classes. So the subclass defines the inner circle and the superclass the outer one. Additionally, we should resume that not all instances of the superclass are instances of the subclass — quite simple regarding previous set based example.
Going further there are supertypes and subtypes and what we should do is to count elements of those types, so generally every element that is of the subtype is actually of the supertype too, and not every element that is of the supertype is of the subtype, which corresponds to the previous claims.
As types feel a little intangible, I’d like to bring a real-life example to make it visible and clear. Consider a supertype Animal and a subtype Cat, we can tell for sure that every cat is an animal, but not every animal is a cat. We can put every cat inside the inner circle, and the rest of the animals inside the outer circle (but separate from cats) and our circle analogy holds. Each person that stops by our circles could say “Every element in the outer circle is an Animal and every element in the inner circle is a Cat. There are also elements (animals) in the outer circle which are not in the inner circle (are not cats).” He could say so, as he pleases.

A relation between types and properties

When we expect supertype somewhere, either it is a function parameter or a variable to assign, we can provide subtype and work with it. Primarily we can provide anything that fits supertype properties because only those properties we expect, and as long as subtype extends supertype, subtype provides not only all properties that supertype but could provide some more.
The same goes for our real-life example. When someone expects an animal, we can pass a cat and make him happy, because the cat has all the properties of the animal. It does not work the other way around, because not every animal is a cat, so someone expecting a cat could be disappointed with receiving another type of animal.

Here is a little bit of TypeScript to make it clearer:

interface Animal {
age: number;
}
interface Cat extends Animal {
// Cat has all the properties of Animal and some more
meow: () => void;
}
// create elements to present our point
const animal: Animal = ...
const cat: Cat = ...
// validate assignment to different types
const expectAnimal: Animal = cat; // OK
const expectCat: Cat = animal; // Error: Animal is not a Cat

Type error emerges when we try to assign element of type Animal to element of type Cat. Further explanations require our circle analogy, so we should present such an example using that intuition. There are way more animals than cats and way more supertype elements than subtype elements and finally more elements in an outer circle than elements in an inner circle, so when we expect some element from the outer circle we can pass any element from the inner circle and satisfy the needs.

Higher order type

The last step before entering colorful terminology is to understand higher order types (HOT), which is lovely abbreviation I must say. HOT is a construction that expects some type as a component, and given that, it creates a more complex type.

// define elements of simple types
const simpleCat: Cat = ...
const simpleAnimal: Animal = ...
// example of HOT
interface someHOT<T> {
additionalProperty: string;
element: T;
workWithElement: (elem: T) => string;
}
// define elements of complex types
const complexCat: someHot<Cat> = ...
const complexAnimal: someHot<Animal> = ...

We use HOT as a kind of function that creates complex type based on simple type provided as a parameter. Covariant and contravariant are characteristics describing specific HOTs.

Covariance

// another example of HOT is `List`
type List<T> = T[];

HOTs let us create complex types List<Animal> and List<Cat> from component types like Animal and Cat. It is easy to see that the list of animals (supertype) and the list of cats (subtype) preserve relations described before because when we expect a list of animals, we are more than happy with a list of cats. That preservation between component and complex types is called covariance.

Now we can finish this nicely by presenting more formal definition — covariance is a higher order type’s trait of preserving subtyping relation of the component types.

This code shows that complex types behave as component ones:

// define elements of complex types
const listOfAnimals: List<Animal> = ...
const listOfCats: List<Cat> = ...
// validate assignment to different complex types
const expectListOfAnimals: List<Animal> = listOfCats; // OK
const expectListOfCats: List<Cat> = listOfAnimals; // Error

Having that we can say that List is covariant because subtyping of component types is the same as subtyping of complex types created by List. Why is this useful? Well, when we know that some HOT is covariant, we can easily reason about subtyping relations regarding complex types created by that HOT, knowing only relations that describe basic, component types.

Back to our circle analogy, they look the same for complex types as for component types, because covariance preserves subtyping relation.

Covariance

Contravariance

When covariance describes HOTs preserving subtyping relation of the component types, contravariance describes HOTs reversing subtyping relation of the component types. When we have contravariant HOT, and we use it with our component types, we expect the reverse relation between complex types:

type ContravariantHot<T> = ...// define elements of complex types
const complexAnimal: ContravariantHot<Animal> = ...
const complexCat: ContravariantHot<Cat> = ...
// validate assignment to different complex types - contravariance// below example alarms Error, contravariance makes it bad
const expectComplexAnimal: ContravariantHot<Animal> = complexCat;
// below example is OK, contravariance makes it ok
const expectComplexCat: ContravariantHot<Cat> = complexAnimal;

As we see relation reverses. Type assignment that was not allowed previously is OK now, and assignment that was allowed before now triggers Error. An important question that remains is what kind of HOT are we talking about. The answer — the functional one. Our HOTs may describe some data structures like List (covariant) as well as functions like Compare (contravariant):

// Compare is a function that compares two elements of given type
type Compare<T> = (left: T, right: T) => number;
// define elements of complex types
const compareAnimals: Compare<Animal> = ...
const compareCats: Compare<Cat> = ...
// validate assignment to different complex types - contravariance
const expectCompareAnimals: Compare<Animal> = compareCats; // Error
const expectCompareCats: Compare<Cat> = compareAnimals; // OK

Of course, our circle analogy is applicable here, but for contravariance example, circles depicting complex types switch places comparing to basic types. For instance, when we use component types of Animal and Cat and create complex types like Compare<Animal> and Compare<Cat> the subtyping relation reverses and we state that Compare<Animal> is the subclass of Compare<Cat>. This relation means that even there are more animals than cats, the correct statement is that there are fewer functions operating on animals than there are functions operating on cats, which may seem counterintuitive at first.

Contravariance

How is this possible and what does it mean to count functions operating on animals? Well, a function is an array of operations over provided parameters. To count possible functions and arrange them in our circles, we need to count possible arrays of operations that define them. That number of all possible arrays of operations grows when the number of operations increases and the number of operations increases when we provide more parameters or more properties. So the more parameters or parameters with more properties we provide, the more possible operations we have to operate over and the more functions we can define.
As we mentioned before subtype which extends supertype has all the properties of supertype and could have some more. This increased complexity conducts the reverse in contravariance definition because the more properties you provide to a function, the more possible operations function can use, and the more overall possible functions we can consider. From the definition of a subset (component type), we received the contravariant definition of superset (complex type).

Conclusion

As we mentioned before, knowing the traits of specific type constructor we can easily reason about subtyping of complex types. This knowledge could be very helpful in organizing functions, data structures and their interfaces that make static typing possible. This subject lies at the root of languages with types, so it is essential to understand those languages completely.

The goal of this text is to introduce and explain mechanics that rule in the world of static typing. Because we used an intuitive approach, some descriptions may not be precise enough, and you may find academic definitions constructed on a different base. Regarding that I suggest to dive into HOTs that accept more than one component type, then the covariance and contravariance relate not to the HOT itself but rather to the specific parameters it receives. All in all, I am sure presented intuition to be helpful to read further.

Thank you for your time.

--

--