Photo by Frame Harirak on Unsplash

Coming Soon: Changes to Object Spreads

Jordan Brown
Aug 20, 2019 · 10 min read

The Flow team is making changes to how Flow models object spreads. In v0.106.0, you’ll start to see some of these changes. When the Flow team talks about our spread model, we usually split it into two separate conversations:

  1. Our model for spreads at the type level (type X = {...A, ...B})
  2. Our model for spreads at the value level (const x = {...a, ...b})

Currently, our model for type spread is sound, but confusing, and our value spread model is both unsound and confusing. Our new model aims to make type spread and value spread work soundly, predictably, and quickly. This post is the first of a few in a series that I’ll make to show the journey we’ve been on, the interesting challenges we faced, and what you can expect as we release these changes.

This post focuses on type spreads, which is the first portion of this work that will be released. In this post, I’ll cover:

  1. How spreads work in JS at runtime
  2. How Flow currently models type spreads
  3. Problems with our current type spread model
  4. The new type spread model and new errors you might see.

TL;DR

Object Spreads at Runtime

const obj1 = {foo: 3, bar: 3}; // Both foo and bar are own properties
obj1.hasOwnProperty('foo'); // true
obj1.hasOwnProperty('baz'); // false, baz is not a property on obj1
// foo and bar are accessible via obj2's prototype chain,
// but they do not belong to obj2 itself
const obj2 = Object.create(obj1);
obj2.foo === 3; // true, we look at obj2's prototype to access foo
obj2.hasOwnProperty('foo'); // false, foo does not belong
// directly to obj2

Object spread. Copies all of the own* properties from an object expression into a location expecting key/value pairs.

const obj1 = {foo: 3, bar: 3}; // foo and bar are both own
const obj2 = {...obj1};
obj1.foo === obj2.foo; // true
obj1.bar === obj2.bar; // true
const obj3 = Object.create({foo: 3});;
obj3.bar = 3;
const obj4 = {...obj3}obj4.foo === obj3.foo; // false, foo is not copied because it is not own
obj4.bar === obj3.bar; // true, bar is copied to obj4 because it is own

A spread will overwrite properties in the object with equal keys. The last specification of the key wins:

const obj1 = {foo: 3, ...{foo: 4}};
obj1.foo === 4; // true
const obj2 = {foo: 3, ...{foo: 4}, foo: 5};
obj2.foo === 5; // true
const obj3 = {foo: 3, ...{bar: 3}, ...{foo: 4, bar: 4}};
obj3.foo === 4; // true
obj3.bar === 4; // true

*Technically, spread only copies over all properties that are both own and enumerable, but enumerability is usually implied by own-ness. In Flow, we do not distinguish between an own enumerable property and own non-enumerable property.

Flow’s Current Model for Type Spreads

declare var a: A;
declare var b: B;
const o: {...A, ...B} = {...a, ...b};

In order to make that happen, Flow has to precisely simulate the runtime semantics of spreads statically.

There are two issues with Flow’s current model. I’ll show examples for each issue in the section, and later I will show how the new model fixes them.

Let’s go over a few assumptions Flow makes and ground ourselves in a few real world examples so that we can understand Flow’s current spread model.

Flow’s object model makes the following assumptions about the various object types you can express:

1: Exact object types specify all own properties. These are useful to specify the structure of objects:

{| foo: number |}, foo is own,

const o1: {| foo: number |} = {foo: 3}; // Ok!
const o2: {| foo: number |} = Object.create({foo: 3}); // Error

2: Inexact object types do not specify own-ness, and only specify a subset of accessible properties. As we will see later, this assumption will change in the new object model:

{ foo: number }, foo may or may not be own

const o1: {foo: number} = {foo: 3}; // Ok!
const o2: {foo: number} = {foo: 3, bar: 3}; // Ok!
const o3: {foo: number} = Object.create({foo: 3}); // Ok!

Now let’s go over a few examples to build an intuition for how Flow uses that information to determine the type of a spread.

Spreading Exact Object Types

Exact object types specify own-ness, so they have very straightforward behavior when you spread them. This simple case is also the most common one, since most teams prefer for their React props type to be exact. We are working on making it even easier to hit the common case by making object types exact by default!

type OtherProps = {|
buttonText: string,
|};
type Props = {|
...OtherProps,
headerText: string,
|};
class Banner extends React.Component<Props> {
// ...
}

Since OtherProps is exact, all of the properties are copied into Props as is. The resulting type for props is then:

type Props = {|
buttonText: string,
headerText: string,
|};

Instantiating the component works exactly how you’d expect it to — all of the properties are required.

Spreading Inexact Object Types

Inexact object types have less straightforward behavior when they are spread.

The first problem is that we cannot make any assumptions about the own-ness of the properties they specify.

type ButtonProps = {
borderShade: number,
};
type Props = {
...ButtonProps,
borderWidth?: number,
color: number,
};
class FormButton extends React.Component<Props> {
// ...
}

Since ButtonProps is inexact, we’re not sure if any of the properties will be copied over in the spread. Because of that, we make all of the properties it copies into Props optional just in case the properties are not own. That gives us the following type checking behavior when instantiating the FormButton components:

// Valid, since all of the properties in Props except color are optional
const intantiation1 = <FormButton color={3} />;
// All ok, since every property is optional except color.
const props1: Props = {borderShade: 3, color: 3};
const props2: Props = {borderWidth: 3, color: 3};
const props3: Props = {color: 3};

[try-flow]

Now, this will almost always cause a type error downstream just where a runtime error is likely to happen. Concretely, let’s take a look at what might be inside the body of FormButton to see what that error might look like:

class FormButton extends React.Component<Props> {
render(): React.Node {
return <Button color={3} borderShade={this.props.borderShade} />; // Error borderShade might be undefined
}
}
const props: Props = {borderShade: 3, color: 3};(props.color: number); // Ok
(props.borderShade: number); // Error, borderShade might be undefined

[try-flow]

Still, finding out that the types don’t mean what you thought they meant when you’re already far away from their definition is quite confusing, and can hide other bugs.

The second problem is that inexact object types don’t specify all of their properties. Thus we make another conservative approximation when spreading an inexact object types after other properties are already specified. Let’s build onto our FormButton example. Suppose we wanted to inject a property later, maybe via an HOC:

type ButtonProps = {
borderShade: number,
};
type InjectedProps = {
transparency: number,
};
type Props = {
...ButtonProps,
borderWidth?: number,
color: number,
...InjectedProps,
};
class FormButton extends React.Component<Props> {
// ...
}

InjectedProps is inexact, so it may contain all of the properties specified before it in Props. Flow will conservatively assume it does to preserve soundness. Since they may be overwritten by properties with unspecified types in InjectedProps, all of the properties specified before the InjectedProps type is spread can only be inferred to have type mixed. And remember, transparency will be optional because InjectedProps is inexact. With that, you get the following instantiation behavior:

const intantiation = <FormButton
color="Not a number!"
/>; // No error, only color is required, and color is typed mixed!
const props: Props = {color: 'string'}; // Ok!

[try-flow]

While this is scary, Flow is probably still preventing any runtime error that this might cause. Whenever you use the fields in the body of the component, you’d realize that the types are actually mixed.

But again, finding out about a latent error that far away from the definition of the type is quite confusing. Isn’t the point of a type checker to flag errors early?

The New Spread Model

  1. The types we infer are imprecise, leading to late errors.
  2. The error messages we do get point to symptoms of the problem instead of the cause. i.e., the issue isn’t that the type is mixed, the issue is that Flow couldn’t infer something more precise than mixed.

Our new model for spread was designed with the following goals in mind:

  1. Infer precise types
  2. When a type can’t be precisely inferred, error on the spread instead of inferring an imprecise type.
  3. Soundly model runtime semantics

Accomplishing these goals with our current assumptions is hard. We simply don’t have enough information to infer more precise types while still maintaining soundness. That means that our object model does not adequately capture the information we need to type check precisely. So if we can’t do better with the information we have, then we need to think carefully about what new information we should track instead.

To that end, we are making changes to our object model that, while disruptive, will allow Flow to more precisely model what happens at runtime and align more closely with user expectations. Spreads are the first part of Flow’s object model that will incorporate these changes.

In the new model, we change our fundamental assumptions about the various different object types in Flow. Most importantly for spreads, our new model has inexact object types specify own properties.

Let’s go over the new assumptions the object model makes about the various object types in Flow.
The biggest change here relevant to spreads is that we now assume that inexact object types are specifying own properties.

1: Exact object types specify all own properties. (This assumption is unchanged):

{| foo: number |}, foo is own

const o1: {| foo: number |} = {foo: 3}; // Ok!
const o2: {| foo: number |} = Object.create({foo: 3}); // Error

2: Inexact object types specify a subset of own properties. Some properties may not be included, and we make no assumptions about the own-ness of unspecified properties:

{ foo: number }, foo is definitely own, but the object may have other properties and we know nothing about their own-ness.

const o1: { foo: number } = {foo: 3}; // Ok!
const o2: { foo: number } = {foo: 3, bar: 3}; // Ok!
const o3: { foo: number } = Object.create({foo: 3}); // Not ok, foo is not own

3: Interfaces specify a subset of properties the object may have and makes no claims about their own-ness:

interface I { foo: number }, foo may or may not be own. There also may be other properties on objects of type I.

interface I { foo: number}
const o1: I = {foo: 3}; // Ok!
const o2: I = {foo: 3, bar: 3}; // Ok!
const o3: I = Object.create({foo: 3}); // Ok!

Now we can revisit our examples from before to see the differences. Since there are no significant differences with exact object types, I’ll focus on the inexact object types.

FormButton

type ButtonProps = {
borderShade: number,
};
type Props = {
...ButtonProps,
borderWidth?: number,
color: number,
};
class FormButton extends React.Component<Props> {
// ...
}

Now that we assume that all of the properties specified by ButtonProps are own, we can infer a much more precise type for Props. All of the props in ButtonProps remain required in Props.

Let’s take another look at the instantiation example:

// Error, borderShade and color are missing!
const intantiation = <FormButton />;
const props: Props = {borderShade: 3, color: 3}; // Ok, all required properties are included

Sweet! That works much more like how expected it to work. Let’s also take a look at the uses of these properties inside the render method:

class FormButton extends React.Component<Props> {
render(): React.Node {
// Ok! props.borderShade has the expected type
return <Button borderShade={this.props.borderShade} ... />;
}
}

Nice. The types we infer in Props match what was specified in the object types, so there is no more magically appearing void type to confuse us.

Let’s take another look at what happens when we add the injected properties:

type ButtonProps = {
borderShade: number,
};
type InjectedProps = {
transparency: number,
};
type Props = {
...ButtonProps,
borderWidth?: number,
color: number,
// Error, InjectedProps is inexact, so borderWidth may be overwritten
...InjectedProps,
};
class FormButton extends React.Component<Props> {
// ...
}

This example is a bit trickier. Since the inexact object type is spread last, it’s possible that other properties will be overwritten. Instead of inferring mixed for all of those potentially overwritten properties and emitting an error at use sites, we will error at the spread.

Here’s what our error looks like:

Cannot determine a type for Props [1]. InjectedProps [2] is inexact, so it may contain `color` with a type that
conflicts with `color`'s definition in Props [1]. Can you make InjectedProps [2] exact?
[1] 12│ type Props = {
13│ ...ButtonProps,
14│ borderWidth?: number,
15│ color: number,
[2] 16│ ...InjectedProps,
17│ };
18│
19│ class FormButton extends React.Component<Props> {

So instead of getting errors when you start using this.props in the render method, you’ll get errors at the spread telling you that Flow could not infer a precise type. This makes the errors much more actionable: instead of wondering about where some mystery type came from downstream, we point out exactly where Flow could not be more precise.

Conclusion

Flow

The official publication for the Flow static type checker