Engineering: Type Variance and Interfaces in Flow.js

Sid Shanker, 28 Mar 2018


At work I spend a lot of time writing code in Javascript and love using Flow, a JS library that adds static typing. It has vastly improved the readability of our codebase, and allows us to refactor code much more easily. I was reading about Flow Interfaces the other day and stumbled upon a section about how to define read-only and write-only properties. The documentation states that whether a property is readable or writable is related to the type variance of the property, which wasn’t totally obvious to me. In this post, I’ll be covering why that relationship exists.

Overview: Type Variances

Subtypes and Supertypes

The first important idea is that in Flow, there is a hierarchy of types. A type is a supertype of another type if the set of objects that match it is a superset of the objects that match the other type. For instance, string | number is a supertype of number, because all objects that comply with the number type also comply with the string | number type. Conversely, number is a subtype of string | number.

Covariance and Contravariance

When defining interfaces, prepending a property name with + makes it covariant, and adding a -to a property makes it contravariant. If a property on an interface is covariant, that means that any implementing class can use the same type specified or a subtype:

interface HasId {
  +id: string | number
}

class Customer implements HasId {
  id: string
}

Similarly, if a property is contravariant, that means that any implementing class can use the same type specified or a supertype:

interface HasId {
  -id: string
}

class Customer implements HasId {
  id: string | number
}

Read-only and write-only properties

A surprising consequence of making a property covariant is that it makes a property read-only, while making a property contravariant makes it write-only:

interface LivingThing {
  +scientific_name: string,
  -english_name: string
};

function method(value: LivingThing) {
  value.scientific_name; // works!
  value.scientific_name = 'Bos taurus'; // errors!
  value.english_name; // errors!
  value.english_name = 'Cow'; // works!
}

Let’s start by considering a why a property that is covariant might be read-only:

interface HasId {
  +id: string | number
}

class Customer implements HasId {
  id: string
}

Since covariant properties, as we established above, can accept subtypes, Customer is a completely valid implementation of the HasId interface.

Let’s now consider a function that accepts a value from HasId:

getId(objectWithId: HasId): string | number {
  var id: (string | number) = objectWithId.id;
  return id;
}

When reading from id, we know that its value also be a string|number, so we can read from it safely, even if objectWithId happens to be an instance of Customer and in that invocation will always be a string.

However, if we try to write to it:

writeNewId(objectWithId: HasId) {
  value.id = 5;
}

the same isn’t the case. Since the property is covariant, even though id is string|number, it is possible for classes that implement HasId, such as Customer, to define id as a subtype, in this case, as string. That means that there is no way in the writeNewId function for objectWithId.id to be assigned in a type-safe manner, since the implementing classes might choose any subtype for id. 5 is a valid value for the string | number type on HasId, but not for Customer. Therefore, making id covariant also makes the property read-only.

A similar logic applies to contravariant properties. These are indicated on interfaces with a -sign. Let’s change up the previous example:

interface HasId {
  -id: string
}

class Customer implements HasId {
  id: string | number
}

In this case, the implementing class is allowed to define id as a superclass of string, since it is contravariant. This means that if we were dealing with an arbitrary object that implements HasId, we cannot make assumptions about what type it might return:

getId(objectWithId: HasId): string {
  var id: string = objectWithId.id;
  return id;
}

Since the contravariant property can be any supertype of the expecting type (in this case string), we can’t read from the value, because it can’t be determined at compile time what exact type it might return.

However, we do know what values we can write to it, because any implementing class would have to be a supertype of those types:

writeNewId(objectWithId: HasId) {
  objectWithId.id = "new string";
}

In this case, we know that since the implementing class must be a supertype of string, this will always be a valid assignment of this property, and therefore, contravariant properties can be written to.

Further Reading

Getting your head around the Flow type system can be challenging, especially for folks coming from experience with Java/C++-like type systems.

If you’re curious, I’d highly recommend taking a look at some of the following doc sections & blog posts about Flow:

If you’re interested in how covariance and contravariance work in other typed languages, I’d highly recommend checking out the Wikipedia page on the subject.

Thanks for reading! Hope this was helpful, and feel free to reach out to me on Twitter if you have any questions or thoughts!