Flow’s best kept secret

Forbes Lindesay
Feb 6, 2017 · 4 min read

One of the features I most often turn to when I’m frustrated by flow’s errors is covariance. That sounds scary (I think this is why it’s not more widely known about) but covariance is actually just a fancy word for read-only. I’ll use the word read-only from now on, so you can forget all about that silly word.

Although it’s much less commonly used (in my experience), contravariance can also be useful. Contravariance is just a fancy word for write-only, so from now on I’ll just call it write-only.

Read Only Properties

Lets consider an example of where you need to mark something as read-only in order for your JavaScript code to type check properly. The following code works fine, even if it is a little contrived:

// @flowfunction logStringContainer(container: {value: string}) {
logNullableStringContainer(container);
}
function logNullableStringContainer(container: {value: ?string}) {
if (container.value != null) {
console.log(container.value);
}
}
logStringContainer({value: 'foo'});

Despite it working fine, flow will give us the following error:

example.js:44:   logNullableStringContainer(container);
^^^^^^^^^
object type. This type is incompatible with the expected param
type of
7: function logNullableStringContainer(container: {value: ?string})
^^^^^^^^^^^^^^^^
object type
Property `value` is incompatible: {value: ?string}
^^^^^^^ null. This type is incompatible with
{value: string}
^^^^^^ string

This is interesting; intuitively, it feels like it should be fine to pass a string into a function that is expecting a ?string, but the problem turns out to be that we could decide to write to that property, as well as reading from it. In fact, inside logNullableStringContainer, flow would see it as completely acceptable for you to set container.value to null. This would violate the contract that of logStringContainer. It would be possible for someone to pass in an object, and be surprised when a property was set to null.

Because we are only reading from the property, there is no bug. All we need to do is tell flow that the property is read-only (covariant). To mark a property as read only, you just prefix it with a +.

// @flowfunction logStringContainer(container: {+value: string}) {
logNullableStringContainer(container);
}
function logNullableStringContainer(container: {+value: ?string}) {
if (container.value != null) {
console.log(container.value);
}
}
logStringContainer({value: 'foo'});

Now when we run flow, we get a result of:

No errors!

Read Only Arrays

The above example only works for when you’ve typed things as plain objects. It won’t work for generics (Arrays, Sets, Maps etc.). Lets take an example of some more code that takes an array, and works fine — but doesn’t type check.

Edit: for Arrays, there is now a built in $ReadOnlyArray<T> that you can use, to handle the Array case, however the following technique is still useful for other containers that you want to treat as read only.
Thanks to @GiulioCanti for pointing this out

// @flowfunction logStrings(values: Array<string>) {
logNullableStrings(values);
}
function logNullableStrings(values: Array<?string>) {
values.forEach(value => {
if (value != null) {
console.log(value);
}
});
}
logStrings(['a', 'b', 'c']);

This clearly works fine, but flow will report:

example.js:44:   logNullableStrings(values);
^^^^^^^^^^^^^^^^^^^^^^^^^^ function call
7: function logNullableStrings(values: Array<?string>) {
^^^^^^^ null.
This type is incompatible with3: function logStrings(values: Array<string>) {
^^^^^^ string

This time, we can guess at what’s gone wrong. Flow can’t tell that we aren’t going to push a new value into the array of values and if we did, we would be within our rights to push null into the array passed to logNullableStrings, but not the array passed to logStrings.

Unfortunately, there’s no easy way to indicate that the array is read only, but we can tell flow what types of array we think should be accepted, and it will prevent us from modifying the array. To do this, we make some clever use of generics:

// @flowfunction logStrings<T: string>(values: Array<T>) {
logNullableStrings(values);
}
function logNullableStrings<T: ?string>(values: Array<T>) {
values.forEach(value => {
if (value != null) {
console.log(value);
}
});
}
logStrings(['a', 'b', 'c']);

Once again, flow reports:

No errors!

This works, because now we are saying we accept Array<T> where T is a ?string. The <T: ?string> is the bit that constrains T to being some sub-class of ?string. For each call of logNullableStrings, that T could be substituted with something else. This means that the argument could end up being Array<string> or Array<null> or Array<?string> or Array<'foo' | 'bar' | void> or any number of other substitutions. Flow is clever enough to pick the right one for the occasion.

Note how this can also be useful if you want to pass through nulls in a function. e.g.

// @flowfunction getValueIfNotNull<T: ?string>(container: {value: T}): T {
return container.value;
}
// flow can tell that the result of the function is not-null if
// the input is not-null
const value: string = getValueIfNotNull({value: 'foo'});

Write Only Properties

Although I’ve found it is generally far less useful, it is possible to mark properties as write-only, using instead of a +. This lets you type code like the following:

// @flowfunction setNullableStringContainer(container: {-value: ?string}) {
setStringContainer(container);
}
function setStringContainer(container: {-value: string}) {
container.value = 'foo';
}
const container = {value: null};
setNullableStringContainer(container);

Write Only Arrays

The approach used for read only arrays (called bounded polymorphism) doesn’t work for write only arrays. The way to handle write only arrays is by declaring an interface, and marking that interface as being write-only (contravariant) for the generic parameter. For example, we could declare the following interface:

// @flowdeclare interface WriteableArray<-T> {
push(...items: Array<T>): number;
unshift(...items: Array<T>): number;
}
function pushNullableString(values: WriteableArray<?string>) {
pushString(values);
}
function pushString(values: WriteableArray<string>) {
pushFoo(values);
}
function pushFoo(values: WriteableArray<'foo'>) {
values.push('foo');
}
pushNullableString([]);

Because Array does have both those two methods, flow will let us pass an Array to a method that expects a WriteableArray. Because all the methods implemented are write-only, we can mark the type parameter T as write-only using <-T>.

Flow Logo

Welcome to a place where words matter. On Medium, smart voices and original ideas take center stage - with no ads in sight. Watch
Follow all the topics you care about, and we’ll deliver the best stories for you to your homepage and inbox. Explore
Get unlimited access to the best stories on Medium — and support writers while you’re at it. Just $5/month. Upgrade

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store