Covariance and Contravariance
Covariance and contravariance are properties of code substitution; they describe what you can and can’t do if you want to replace some code (e.g., a function call) with some other code (e.g., a different function call) in a type-safe way.
I initially found the concept of covariance and contravariance to be difficult to grasp. This post starts with types and slowly builds to a practical example of covariance and contravariance in a way that will hopefully help you understand it when needed.
Types
In TypeScript, you could define and use types to describe objects like this:
// Define the type Foo and use it by name:
type Foo = { name: string, age: number };
const foo: Foo = { name: 'Geralt', age: 98 };
// Use the type directly:
const bar: { a: number, b: number } = { a: 1, b: 2 };
console.log(bar.a); // 1
console.log(bar.b); // 2
I think the concept of variance can be more simply and intuitively understood
with just the existence of fields, so let’s consider a type system that
contains only object types, and say that the object types can either have a
field or not, and there is no regard for whether the field would hold a string,
boolean, number, etc.
As examples,
this is a type that has the fields name and age: { name, age }
,
and this is a type that has the fields a, b, and c: { a, b, c }
.
Subtypes and Supertypes
Subtypes extend other types. In our simplified type system that only considers the existence of a field, subtypes have more fields than the simpler types they extend. Restated, the set of fields of a subtype are a superset of the fields of the type it extends.
The symbol <:
means “is a subtype of”,
so S <: T
means that some type S
is a subtype of T
.
Using the field syntax from before, this is a chain of subtypes:
{ a, b, c } <: { a, b } <: { a } <: { }
Going the other way, the symbol :>
means “is a supertype of”,
so T :> S
means that some type T
is a supertype of S
.
This is a chain of supertypes:
{ } :> { d } :> { d, e } :> { d, e, f }
Base Code
Let’s define a function foo
that takes an input with a
and b
fields
and returns an output with an x
field.
This code constructs some input for foo
with the in
variable,
and collect foo
s output in the out
variable.
function foo(arg: { a, b }) -> { x }
return { x: arg.a + arg.b }
var in: { a, b } = { a: 1, b: 2 }
var out: { x } = foo(in)
print(out.x) // 3
We are going to modify the above code in several ways to explore when changing a function’s parameter type and return type is valid and invalid. ✅ indicates that the modification in the section produces a valid program. ❌ indicates that the modification produces an invalid program.
✅ The Caller Subtypes the Argument
Let’s add another field c
to the in
variable before passing it into foo
,
but keep the implementation of foo
the same.
This program is still valid because it is valid for any caller of foo
to pass
in any subtype of { a, b }
; after all, foo
only needs to be able to access
the a
and b
fields of the parameter.
function foo(arg: { a, b }) -> { x }
return { x: arg.a + arg.b }
var in: { a, b, c } = { a: 1, b: 2, c: 3 }
var out: { x } = foo(in)
print(out.x) // 3
✅ The Function Subtypes the Return Type
This time, let’s change foo
s implementation and add another field y
to the
return type, but keep the calling code the same.
The code that comes after the call to foo
only attempts to access the x
field, so it ok for foo
to return any subtype of { x }
and this program
remains valid.
function foo(arg: { a, b }) -> { x, y }
return { x: arg.a, y: arg.b }
var in: { a, b } = { a: 1, b: 2 }
var out: { x } = foo(in)
print(out.x) // 3
❌ The Function Subtypes the Argument Type
Let’s modify foo
to require its parameter to have a c
field,
but keep the calling site the same.
This program is no longer valid because the input to foo
does not have a c
.
function foo(arg: { a, b, c }) -> { x }
return { x: arg.a + arg.b + arg.c }
var in: { a, b } = { a: 1, b: 2 }
var out: { x } = foo(in) // Error: 'in' is missing the 'c' field.
print(out.x)
✅ The Function Supertypes the Argument Type
This is the same case as when
the caller subtypes the argument,
but from the perspective of replacing the function foo
.
function foo(arg: { a }) -> { x }
return { x: arg.a }
var in: { a, b } = { a: 1, b: 2 }
var out: { x } = foo(in)
print(out.x) // 1
❌ The Function Supertypes the Return Type
Let’s modify foo
to return the type { }
(a supertype of { x }
),
but keep the calling site the same.
This program is no longer valid because the output of foo
does not have an
x
.
function foo(arg: { a, b }) -> { }
return { }
var in: { a, b } = { a: 1, b: 2 }
var out: { x } = foo(in) // Error: no 'x' on return type of 'foo'.
print(out.x)
TypeScript Example
The following TypeScript is an implementation of the valid and invalid function substitution that we’ve looked at so far.
type A = { a };
type AB = { a, b };
type ABC = { a, b, c };
type X = { x };
type XY = { x, y };
interface Type {
foo: (arg: AB) => X
}
interface ValidSubType extends Type {
// Imposes fewer restrictions on the input it needs and guarantees to
// return the fields you would expect from calling Type.foo.
foo: (arg: A) => XY
}
interface InvalidSubTypeBadInput extends Type {
// Tries to impose a new requirement on calls to foo (that the input will
// have a "c" field).
foo: (arg: ABC) => X
}
interface InvalidSubTypeBadOutput extends Type {
// Does not fulfill the promise made by "foo" (that the return type will
// have an "x" field).
foo: (arg: AB) => { }
}
If you view the code in the
TypeScript Playground,
you will see the errors
Interface 'InvalidSubTypeBadInput' incorrectly extends interface 'Type'.
and
Interface 'InvalidSubTypeBadOutput' incorrectly extends interface 'Type'.
along with additional information on what is incorrect.
Let’s focus on the ValidSubType
and connect it to covariance and
contravariance:
ValidSubType
is a subtype ofType
, andValidSubType
’s return typeXY
is a subtype ofType
’s return typeX
. Since the type relation ofValidSubType <: Type
is the same direction asXY <: X
, a function return type is said to be covariant.ValidSubType
is a subtype ofType
, andValidSubType
’s argument typeA
is a supertype ofType
’s argument typeAB
. Since the type relation ofValidSubType <: Type
is the opposite direction asAB :> A
, a function argument type is said to be contravariant.
In this post I have focused on the idea of substitution for a function call, but the property of substitution applies to arrays and generics too. The Wikipedia article on covariance and contravariance has more information.