Type Variance and TypeScript

Coming from a dynamic language (JavaScript, for example) into a type-safe language can be a frustrating experience. Your shoot-from-the-hip programming is replaced with carefully thought-out type definitions and--sometimes--painfully slow logic creation. But, in the end, you can end up with a fewer percentage of bugs (~15%) shipped to production.

I would frequently read the terms 'contravariance', 'covariance', and 'invariance' in nitty-gritty details of type-safe language implementation (TypeScript being one of them), but I failed to wrap my head around some of their more formal definitions. Take this example from Wikipedia:

Within the type system of a programming language, a typing rule or a type constructor is:

- covariant if it preserves the ordering of types (≤), which orders types from more specific to more generic;

- contravariant if it reverses this ordering;

- bivariant if both of these apply (i.e., both I<A> ≤ I<B> and I<B> ≤ I<A> at the same time)[1];

- invariant or nonvariant if neither of these applies.

* furrows brow in a vain attempt to understand the situation *

Now that I believe I understand these concepts to a small degree, I'm going to try to explain it in different language. Hopefully someone who was/is having conceptual difficulties with these terms will find it useful.

First, it's helpful that a newcomer to these concepts understands some basic Set theory. But really, it's easy. We're talking very basic Set theory. We need in our vocabulary the terminology: superset, subset, and of course set.

Set

A group of "things" classified in any way we want. We could have a set of Mammals or a set of blonde-haired actors. It's arbitrarily defined. (Of course, there are also formal definitions, but that's not important for the time being)

Superset

A larger group of "things" which encompasses (has as a part of itself) another set. We could have a set of cats and a superset of Mammals. All cats are Mammals, thus Mammals would be the superset of cats.

Subset

The inverse of superset, the set within another set. Using our example above: cats would be a subset of Mammals.

Okay, but how does this relate to programming languages?


Types

Hopefully, we--as programmers--intuitively understand what a "type" is in the context of a programming language. Specifically, TypeScript's (TypeScript, by the way, is a superset of JavaScript) types are:

  • unknown
  • undefined
  • void
  • any

    • null
    • number
    • enums

    • bigint
    • boolean
    • string
    • symbol
    • object
    • array

      • tuple

    • function
    • constructor

Here, nesting signifies a super/subset relationship

So we see that almost everything is a subset of any, tuple is a subset of array, which is a subset of object, and so on. We also have never (not listed above) which is a subtype of everything and a superset of nothing, i.e. it is the innermost set.

This relationship amongst the primitives is easily grasped, I think. What's more interesting is when we start to construct our own types. Let's see an example:

type OurNewType = number | object

It's easy to figure out what the subset (i.e. subtype) of tuple is (answer: never), but what about OurNewType? This isn't nearly as complicated as it can get, but it's still worth the exercise. We can figure the subtypes of OurNewType by first looking at its constituents: number and object. What are their subtypes? Looking at the relationships noted above we see we have array and tuple as a subtype of object, and we also have enums as a subtype of number. And, again, never, which always tags along for the ride as being in the list of subtypes. So we definitely know that we have four subtypes. But further, we also have the top-level constituents of OurNewType as well, i.e. object and number. So number is a subtype of OurNewType as is object. Together these two form a set so, naturally, they're a subset of the set they help compose.

To complicate matters more (maybe?) we also have the notion of supertypes and subtypes (supersets and subsets) of objects we define. In other words, types we create that aren't explicitly composed of primitives (like OurNewType is). Particularly, I'm referring to classes (in the Object Oriented sense).

Let's go back to the beginning of this article when I mentioned that Cats was a subset of Mammals. For illustration let's create another set, which will be a superset of Mammal and we'll call it...oh, I dunno...Chordata. Let's now create these classes, in TypeScript:

class Chordate {
    hasSpine() {
        return true
    }
}

class Mammal extends Chordate {
    hasLiveYoung() {
        return true
    }
}

class Cat extends Mammal {
    meows() {
        return true
    }
}

const kitty: Cat = new Cat()

kitty.meows()        // true
kitty.hasLiveYoung() // true
kitty.hasSpine()     // true

The supertype/subtype relationship is easy to see here. Since Cat extends Mammal, which extends Chordate, Cat "has access" to the attributes of those which it extends from. This does not work bi-directionally, as also seems obvious:

const someChordate: Chordate = new Chordate()

someChordate.meows() // true? false? Guess it depends...

The point is, since Chordate is a supertype of Cat it (someChordate) could be a Cat, but it isn't "for sure". It could be a fish, for example...which, cat fish puns aside, don't meow. In mathematical notation (Sorry, this makes it less verbose. Plus, it looks cool.) we describe this relationship as:

$$ Cat \subset Chordate $$

Which says that Cat is a subset (subtype) of Chordate. We can also symbolize "subset or equal set" like so:

$$ A \subseteq B $$

Which says A is a subset of (or the same set as) B. With these ideas and ways of expressing them in hand we can move forward with the meat of this post.

So, what is...

Covariance

The ability of an Object or Class to accept types which are subtypes (or equivalent types) to a given set of parameters. For instance, if a class constructor accepts a parameter of type P and we supply an argument of type A TypeScript (as an example) enforces that

$$ A \subseteq P $$

otherwise it won't transpile our code.

Contravariance

This is the opposite of Covariance. Instead of subtype acceptance we're looking at supertype acceptance. It's important to note that in TypeScript only functions are contravariant. That is, functions only accept arguments of type A if their relationship to the parameter type P is

$$ A \supseteq P $$

Weird, right? That seems counter-intuitive. It isn't. But it certainly feels that it is.

These last two are easy...

Bivariance

It doesn't matter if it's a supertype or subtype, either will work. A silly example of a bivariant function would be

function justReturnIt(thing: any): any { return thing }

Invariance

This is the need for exactly a specific type. Say you have a function that concatenates two strings

function concat(s1: string, s1: string): string { /* blah blah */ }

We would say this function is invariant with respect to its types. In other words, give it exactly what it wants.