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.
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)
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.
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?
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...
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.
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...
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 }
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.