Skip to main content

Property Variance and Other Upcoming Changes

The next release of Flow, 0.34, will include a few important changes to object types:

  • property variance,
  • invariant-by-default dictionary types,
  • covariant-by-default method types,
  • and more flexible getters and setters.

What is Variance?

Defining the subtype relationship between types is a core responsibility of Flow as a type system. These relationships are determined either directly for simple types or, for complex types, defined in terms of their parts.

Variance describes the subtyping relationship for complex types as it relates to the subtyping relationships of their parts.

For example, Flow directly encodes the knowledge that string is a subtype of ?string. Intuitively, a string type contains string values while a ?string type contains null, undefined, and also string values, so membership in the former naturally implies membership in the later.

The subtype relationships between two function types is not as direct. Rather, it is derived from the subtype relationships between the functions' parameter and return types.

Let's see how this works for two simple function types:

type F1 = (x: P1) => R1;
type F2 = (x: P2) => R2;

Whether F2 is a subtype of F1 depends on the relationships between P1 and P2 and R1 and R2. Let's use the notation B <: A to mean B is a subtype of A.

It turns out that F2 <: F1 if P1 <: P2 and R2 <: R1. Notice that the relationship for parameters is reversed? In technical terms, we can say that function types are "contravariant" with respect to their parameter types and "covariant" with respect to their return types.

Let's look at an example:

function f(callback: (x: string) => ?number): number {
return callback("hi") || 0;
}

What kinds of functions can we pass to f? Based on the subtyping rule above, then we can pass a function whose parameter type is a supertype of string and whose return type is a subtype of ?number.

function g(x: ?string): number {
return x ? x.length : 0;
}
f(g);

The body of f will only ever pass string values into g, which is safe because g takes at least string by taking ?string. Conversely, g will only ever return number values to f, which is safe because f handles at least number by handling ?number.

Input and Output

One convenient way to remember when something is covariant vs. contravariant is to think about "input" and "output."

Parameters are in an input position, often called a "negative" position. Complex types are contravariant in their input positions.

Return is an output position, often called a "positive" position. Complex types are covariant in their output positions.

Property Invariance

Just as function types are composed of parameter and return types, so too are object types composed of property types. Thus, the subtyping relationship between objects is derived from the subtyping relationships of their properties.

However, unlike functions which have input parameters and an output return, object properties can be read and written. That is, properties are both input and output.

Let's see how this works for two simple object types:

type O1 = {p: T1};
type O2 = {p: T2};

As with function types, whether O2 is a subtype of O1 depends on the relationship between its parts, T1 and T2.

Here it turns out that O2 <: O1 if T2 <: T1 and T1 <: T2. In technical terms, object types are "invariant" with respect to their property types.

Let's look at an example:

function f(o: {p: ?string}): void {
// We can read p from o
let len: number;
if (o.p) {
len = o.p.length;
} else {
len = 0;
}

// We can also write into p
o.p = null;
}

What kinds of objects can we pass into f, then? If we try to pass in an object with a subtype property, we get an error:

var o1: {p: string} = {p: ""};
f(o1);
function f(o: {p: ?string}) {}
^ null. This type is incompatible with
var o1: {p: string} = {p: ""};
^ string
function f(o: {p: ?string}) {}
^ undefined. This type is incompatible with
var o1: {p: string} = {p: ""};
^ string

Flow has correctly identified an error here. If the body of f writes null into o.p, then o1.p would no longer have type string.

If we try to pass an object with a supertype property, we again get an error:

var o2: {p: ?(string|number)} = {p: 0};
f(o2);
var o1: {p: ?(string|number)} = {p: ""};
^ number. This type is incompatible with
function f(o: {p: ?string}) {}
^ string

Again, Flow correctly identifies an error, because if f tried to read p from o, it would find a number.

Property Variance

So objects have to be invariant with respect to their property types because properties can be read from and written to. But just because you can read and write, doesn't mean you always do.

Consider a function that gets the length of an nullable string property:

function f(o: {p: ?string}): number {
return o.p ? o.p.length : 0;
}

We never write into o.p, so we should be able to pass in an object where the type of property p is a subtype of ?string. Until now, this wasn't possible in Flow.

With property variance, you can explicitly annotate object properties as being covariant and contravariant. For example, we can rewrite the above function:

function f(o: {+p: ?string}): number {
return o.p ? o.p.length : 0;
}

var o: {p: string} = {p: ""};
f(o); // no type error!

It's crucial that covariant properties only ever appear in output positions. It is an error to write to a covariant property:

function f(o: {+p: ?string}) {
o.p = null;
}
o.p = null;
^ object type. Covariant property `p` incompatible with contravariant use in
o.p = null;
^ assignment of property `p`

Conversely, if a function only ever writes to a property, we can annotate the property as contravariant. This might come up in a function that initializes an object with default values, for example.

function g(o: {-p: string}): void {
o.p = "default";
}
var o: {p: ?string} = {p: null};
g(o);

Contravariant properties can only ever appear in input positions. It is an error to read from a contravariant property:

function f(o: {-p: string}) {
o.p.length;
}
o.p.length;
^ object type. Contravariant property `p` incompatible with covariant use in
o.p.length;
^ property `p`

Invariant-by-default Dictionary Types

The object type {[key: string]: ?number} describes an object that can be used as a map. We can read any property and Flow will infer the result type as ?number. We can also write null or undefined or number into any property.

In Flow 0.33 and earlier, these dictionary types were treated covariantly by the type system. For example, Flow accepted the following code:

function f(o: {[key: string]: ?number}) {
o.p = null;
}
declare var o: {p: number};
f(o);

This is unsound because f can overwrite property p with null. In Flow 0.34, dictionaries are invariant, like named properties. The same code now results in the following type error:

function f(o: {[key: string]: ?number}) {}
^ null. This type is incompatible with
declare var o: {p: number};
^ number
function f(o: {[key: string]: ?number}) {}
^ undefined. This type is incompatible with
declare var o: {p: number};
^ number

Covariant and contravariant dictionaries can be incredibly useful, though. To support this, the same syntax used to support variance for named properties can be used for dictionaries as well.

function f(o: {+[key: string]: ?number}) {}
declare var o: {p: number};
f(o); // no type error!

Covariant-by-default Method Types

ES6 gave us a shorthand way to write object properties which are functions.

var o = {
m(x) {
return x * 2
}
}

Flow now interprets properties which use this shorthand method syntax as covariant by default. This means it is an error to write to the property m.

If you don't want covariance, you can use the long form syntax:

var o = {
m: function(x) {
return x * 2;
}
}

More Flexible Getters and Setters

In Flow 0.33 and earlier, getters and setters had to agree exactly on their return type and parameter type, respectively. Flow 0.34 lifts that restriction.

This means you can write code like the following:

// @flow
declare var x: string;

var o = {
get x(): string {
return x;
},
set x(value: ?string) {
x = value || "default";
}
}