Skip to content

Using flow, Record#set and Record#merge return intersection types (v4) #1388

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
acusti opened this issue Oct 16, 2017 · 12 comments
Closed

Using flow, Record#set and Record#merge return intersection types (v4) #1388

acusti opened this issue Oct 16, 2017 · 12 comments

Comments

@acusti
Copy link
Contributor

acusti commented Oct 16, 2017

What happened

In trying to upgrade draft-js to 4.0.0-rc.7 (and flow 0.57.1), I ran into many errors all around the fact that calling .set() or .merge() on a Record returns an intersection type that then causes errors for any other typing that refers to the original Record, or in the case of draft, that refers to a class that extends the Record, which is the common pattern in that codebase. For example:

ContentState.js, the Record-based class:

const defaultRecord: {
  entityMap: ?any,
  blockMap: ?BlockMap,
  selectionBefore: ?SelectionState,
  selectionAfter: ?SelectionState,
} = {
  entityMap: null,
  blockMap: null,
  selectionBefore: null,
  selectionAfter: null,
};

const ContentStateRecord = Record(defaultRecord);

class ContentState extends ContentStateRecord {
  ...
}

updateEntityDataInContentState.js:

import type ContentState from 'ContentState';

function updateEntityDataInContentState(
  contentState: ContentState,
  key: string,
  data: {[key: string]: any},
  merge: boolean,
): ContentState {
  const instance = contentState.getEntity(key);
  const entityData = instance.getData();
  const newData = merge ?
    {...entityData, ...data} :
    data;

  const newInstance = instance.set('data', newData);
  const newEntityMap = contentState.getEntityMap().set(key, newInstance);
  return contentState.set('entityMap', newEntityMap);
}

Which leads to:

Error: src/model/transaction/updateEntityDataInContentState.js:32
 32:   return contentState.set('entityMap', newEntityMap);
              ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ intersection. This type is incompatible with
 23: ): ContentState {
        ^^^^^^^^^^^^ ContentState
  Member 1:
  1395:   set<K: $Keys<T>>(key: K, value: $ElementType<T, K>): this & T;
                                                               ^^^^ `this` type. See: node_modules/immutable/dist/immutable.js.flow:1395
  Error:
  1395:   set<K: $Keys<T>>(key: K, value: $ElementType<T, K>): this & T;
                                                               ^^^^ RecordInstance. This type is incompatible with the expected return type of. See: node_modules/immutable/dist/immutable.js.flow:1395
   23: ): ContentState {
          ^^^^^^^^^^^^ ContentState
  Member 2:
   60: const ContentStateRecord = Record(defaultRecord);
                                  ^^^^^^^^^^^^^^^^^^^^^ type parameter `Values` of function call. See: src/model/immutable/ContentState.js:60
  Error:
  1387:   get<K: $Keys<T>>(key: K): $ElementType<T, K>;
                                    ^^^^^^^^^^^^^^^^^^ element type. Indexable signature not found in. See: node_modules/immutable/dist/immutable.js.flow:1387
   23: ): ContentState {
          ^^^^^^^^^^^^ ContentState

Is there a way to cast the result of every set and merge operation somehow to ensure that the value can still be typed as the original record type? Or any other recommended way to get around this issue?

Also, @leebyron, is opening issues here a useful way for me to report issues I come across as I work through getting draft.js upgraded (facebookarchive/draft-js#1439)? @flarnie mentioned that you were open to suggestions or questions; if there’s a better venue for that, please let me know. Thanks!

@leebyron
Copy link
Collaborator

These intersection types are a feature of v4 to get much better Record typing, though unfortunately there are still tradeoffs. One of those tradeoffs is that Record subclassing is not very well supported.

Essentially every new Record factory is a constructor of a subclass of RecordInstance, but Flow does not have a way to dynamically create subclass types, so the work around is this intersection type which emulates a subclass.

Typically these Record subclasses are just collections of methods, one strategy for updating them might be to convert the subclass into just a collection of functions which takes a Record as a first argument. Though I know that could be a really invasive change to Draft.js which heavily relies on record methods.

I'm definitely open to suggestions for improving the types for Record subclassing if you have ideas

@leebyron
Copy link
Collaborator

I've asked @calebmer to help figure out a way to improve this, since I understand subclassing is a really common existing pattern.

So far we've realized that the this binding in the resulting class is not what we expected: https://flow.org/try/#0CYUwxgNghgTiAEkoGdnwBrwN4Ch72RABcAKASgC54iALAS2QG4cBfHHUJOeAMwFcAdmCJ0A9gPgAlcKJjByVAMLRUAHnQA+ZjjDjkReAE14AXiky55bUlTwAWvBAAPIiAHA0xrGxwkBIAHd7cgA6QlJKezJmIA

@leebyron
Copy link
Collaborator

leebyron commented Oct 17, 2017

One work around I've figured out is to override the problem methods directly to the subclass:

class ContentState extends ContentStateRecord {
  set(k, v): this {
    return (super.set(k, v): any);
  }
}

The root issue is that Flow misunderstands the type this refers to for the subclass, and overriding removes that misunderstanding, even though it's not doing anything new.

@athomann
Copy link

athomann commented Oct 17, 2017

Delete comment to clear up issue. #1398

@leebyron
Copy link
Collaborator

@athomann - hard to know without more information

@leebyron
Copy link
Collaborator

@athomann - that definitely looks like a typing bug, but I'm not certain what it is yet. Either way it's different from this one - would you mind opening a new issue?

@acusti
Copy link
Contributor Author

acusti commented Oct 18, 2017

@leebyron I tried your workaround of overriding set and merge directly on the Record subclass, and that does prevent flow from reporting errors when invoking set() and merge() on instances of the class. However, I now am seeing errors when constructing a new instance:

Error: src/model/paste/DraftPasteProcessor.js:55
                                     v
 55:         return new ContentBlock({
 56:           key: generateRandomKey(),
 57:           type,
...:
 60:         });
             ^ object literal. This type is incompatible with the expected param type of
1399:   constructor (values?: Iterable<[$Keys<T>, $ValOf<T>]> | $Shape<T>): void;
                              ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ union: type application of identifier `Iterable` | type parameter `Values` of function call. See: node_modules/immutable/dist/immutable.js.flow:1399
  Member 1:
  1399:   constructor (values?: Iterable<[$Keys<T>, $ValOf<T>]> | $Shape<T>): void;
                                ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ type application of identifier `Iterable`. See: node_modules/immutable/dist/immutable.js.flow:1399
  Error:
                                       v
   55:         return new ContentBlock({
   56:           key: generateRandomKey(),
   57:           type,
  ...:
   60:         });
               ^ object literal. This type is incompatible with
  1399:   constructor (values?: Iterable<[$Keys<T>, $ValOf<T>]> | $Shape<T>): void;
                                ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ $Iterable. See: node_modules/immutable/dist/immutable.js.flow:1399
    Property `@@iterator` is incompatible:
      1399:   constructor (values?: Iterable<[$Keys<T>, $ValOf<T>]> | $Shape<T>): void;
                                    ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ property `@@iterator` of $Iterable. Property not found in. See: node_modules/immutable/dist/immutable.js.flow:1399
                                           v
       55:         return new ContentBlock({
       56:           key: generateRandomKey(),
       57:           type,
      ...:
       60:         });
                   ^ object literal
  Member 2:
   48: var ContentBlockRecord = Record(defaultRecord);
                                ^^^^^^^^^^^^^^^^^^^^^ type parameter `Values` of function call. See: src/model/immutable/ContentBlock.js:48
  Error:
                                       v
   55:         return new ContentBlock({
   56:           key: generateRandomKey(),
   57:           type,
  ...:
   60:         });
               ^ property `characterList` of object literal. Property not found in
   57:   merge(obj: Object): this {
                             ^^^^ ContentBlock. See: src/model/immutable/ContentBlock.js:57

ContentBlock looks like:

var defaultRecord: {
  key: string,
  type: DraftBlockType,
  text: string,
  characterList: List<CharacterMetadata>,
  depth: number,
  data: Map<any, any>,
} = {
  key: '',
  type: 'unstyled',
  text: '',
  characterList: List(),
  depth: 0,
  data: Map(),
};

var ContentBlockRecord = Record(defaultRecord);

class ContentBlock extends ContentBlockRecord {
  // Override problematic Record methods directly in the subclass
  // https://github.com/facebook/immutable-js/issues/1388#issuecomment-337316952
  set(k, v): this {
    return super.set(k, v);
  }

  merge(obj: Object): this {
    return super.merge(obj);
  }

  ...
}

@leebyron
Copy link
Collaborator

Happy to see progress.

Is there more to that error message? Flow's identifying that it's not Iterable, but I'd expect something to say about the $Shape as well. Maybe I'll flip those back since constructing from a $Shape is probably more common.

Anothing thing we've noticed before is that Flow has trouble pinning down the type of values in the Record. We added some new utility types to help with this. I'm not sure if that's the issue at hand, but it might help:

import type { RecordFactory } from 'immutable';

type ContentBlockFields = {
  key: string,
  type: DraftBlockType,
  text: string,
  characterList: List<CharacterMetadata>,
  depth: number,
  data: Map<any, any>,
};

var defaultRecord: ContentBlockFields = {
  key: '',
  type: 'unstyled',
  text: '',
  characterList: List(),
  depth: 0,
  data: Map(),
};

var ContentBlockRecord: RecordFactory<ContentBlockFields> = Record(defaultRecord);

class ContentBlock extends ContentBlockRecord {
  // Override problematic Record methods directly in the subclass
  // https://github.com/facebook/immutable-js/issues/1388#issuecomment-337316952
  set(k, v): this & ContentBlockFields {
    return super.set(k, v);
  }

  merge(obj: Object): this & ContentBlockFields {
    return super.merge(obj);
  }

  ...
}

@acusti
Copy link
Contributor Author

acusti commented Oct 19, 2017

That’s the whole error message (no mention of $Shape), which is strange.

But either way, good news! Using RecordFactory along with the this & Fields refinement on the set and merge proxy methods got rid of around 60 errors, so it looks like a viable workaround on the whole.

@acusti
Copy link
Contributor Author

acusti commented Oct 19, 2017

I spoke too soon. Making the above changes did greatly reduce the total error count, but flow still has all kinds of complaints about those subclassed Records.

Error: src/model/immutable/SelectionState.js:47
 47:     return super.set(k, v);
                ^^^^^^^^^^^^^^^ intersection. This type is incompatible with
 46:   set(k: string, v: any): this & Fields {
                               ^^^^ SelectionState
  Member 1:
  1418:   set<K: $Keys<T>>(key: K, value: $ElementType<T, K>): this & T;
                                                               ^^^^ `this` type. See: node_modules/immutable/dist/immutable.js.flow:1418
  Error:
  1418:   set<K: $Keys<T>>(key: K, value: $ElementType<T, K>): this & T;
                                                               ^^^^ RecordInstance. This type is incompatible with the expected return type of. See: node_modules/immutable/dist/immutable.js.flow:1418
   46:   set(k: string, v: any): this & Fields {
                                 ^^^^ SelectionState
  Member 2:
   47:     return super.set(k, v);
                  ^^^^^^^^^^^^^^^ type parameter `T` of call of method `set`
  Error:
   40: var SelectionStateRecord: RecordFactory<Fields> = Record(defaultRecord);
                                               ^^^^^^ object type. This type is incompatible with the expected return type of
   46:   set(k: string, v: any): this & Fields {
                                 ^^^^ SelectionState

Error: src/model/immutable/SelectionState.js:47
 47:     return super.set(k, v);
                ^^^^^^^^^^^^^^^ intersection. This type is incompatible with
 46:   set(k: string, v: any): this & Fields {
                               ^^^^ some incompatible instantiation of `this`
  Member 1:
  1418:   set<K: $Keys<T>>(key: K, value: $ElementType<T, K>): this & T;
                                                               ^^^^ `this` type. See: node_modules/immutable/dist/immutable.js.flow:1418
  Error:
  1418:   set<K: $Keys<T>>(key: K, value: $ElementType<T, K>): this & T;
                                                               ^^^^ RecordInstance. This type is incompatible with the expected return type of. See: node_modules/immutable/dist/immutable.js.flow:1418
   46:   set(k: string, v: any): this & Fields {
                                 ^^^^ some incompatible instantiation of `this`
  Member 2:
   47:     return super.set(k, v);
                  ^^^^^^^^^^^^^^^ type parameter `T` of call of method `set`
  Error:
   40: var SelectionStateRecord: RecordFactory<Fields> = Record(defaultRecord);
                                               ^^^^^^ object type. This type is incompatible with the expected return type of
   46:   set(k: string, v: any): this & Fields {
                                 ^^^^ some incompatible instantiation of `this`

Error: src/model/immutable/SelectionState.js:47
 47:     return super.set(k, v);
                          ^ some string with unknown value. Property not found in
1418:   set<K: $Keys<T>>(key: K, value: $ElementType<T, K>): this & T;
               ^^^^^^^^ object type. See: node_modules/immutable/dist/immutable.js.flow:1418

Error: src/model/immutable/SelectionState.js:51
 51:     return super.merge(obj);
                ^^^^^^^^^^^^^^^^ intersection. This type is incompatible with
 50:   merge(obj: Object): this & Fields {
                           ^^^^ SelectionState
  Member 1:
  1420:   merge(...collections: Array<Iterable<[$Keys<T>, $ValOf<T>]> | $Shape<T>>): this & T;
                                                                                     ^^^^ `this` type. See: node_modules/immutable/dist/immutable.js.flow:1420
  Error:
  1420:   merge(...collections: Array<Iterable<[$Keys<T>, $ValOf<T>]> | $Shape<T>>): this & T;
                                                                                     ^^^^ RecordInstance. This type is incompatible with the expected return type of. See: node_modules/immutable/dist/immutable.js.flow:1420
   50:   merge(obj: Object): this & Fields {
                             ^^^^ SelectionState
  Member 2:
   51:     return super.merge(obj);
                  ^^^^^^^^^^^^^^^^ type parameter `T` of call of method `merge`
  Error:
   40: var SelectionStateRecord: RecordFactory<Fields> = Record(defaultRecord);
                                               ^^^^^^ object type. This type is incompatible with the expected return type of
   50:   merge(obj: Object): this & Fields {
                             ^^^^ SelectionState

Error: src/model/immutable/SelectionState.js:51
 51:     return super.merge(obj);
                ^^^^^^^^^^^^^^^^ intersection. This type is incompatible with
 50:   merge(obj: Object): this & Fields {
                           ^^^^ some incompatible instantiation of `this`
  Member 1:
  1420:   merge(...collections: Array<Iterable<[$Keys<T>, $ValOf<T>]> | $Shape<T>>): this & T;
                                                                                     ^^^^ `this` type. See: node_modules/immutable/dist/immutable.js.flow:1420
  Error:
  1420:   merge(...collections: Array<Iterable<[$Keys<T>, $ValOf<T>]> | $Shape<T>>): this & T;
                                                                                     ^^^^ RecordInstance. This type is incompatible with the expected return type of. See: node_modules/immutable/dist/immutable.js.flow:1420
   50:   merge(obj: Object): this & Fields {
                             ^^^^ some incompatible instantiation of `this`
  Member 2:
   51:     return super.merge(obj);
                  ^^^^^^^^^^^^^^^^ type parameter `T` of call of method `merge`
  Error:
   40: var SelectionStateRecord: RecordFactory<Fields> = Record(defaultRecord);
                                               ^^^^^^ object type. This type is incompatible with the expected return type of
   50:   merge(obj: Object): this & Fields {
                             ^^^^ some incompatible instantiation of `this`

SelectionState.js looks like:

type Fields = {
  anchorKey: string,
  anchorOffset: number,
  focusKey: string,
  focusOffset: number,
  isBackward: boolean,
  hasFocus: boolean,
};

var defaultRecord: Fields = {
  anchorKey: '',
  anchorOffset: 0,
  focusKey: '',
  focusOffset: 0,
  isBackward: false,
  hasFocus: false,
};

var SelectionStateRecord: RecordFactory<Fields> = Record(defaultRecord);

class SelectionState extends SelectionStateRecord {

  // Override problematic Record methods directly in the subclass
  // https://github.com/facebook/immutable-js/issues/1388#issuecomment-337316952
  set(k: string, v: any): this & Fields {
    return super.set(k, v);
  }

  merge(obj: Object): this & Fields {
    return super.merge(obj);
  }

  ...
}

@leebyron
Copy link
Collaborator

Yeah - this is the same error as before but now locallzed to your override fix. You can tell Flow to leave you alone since we're already doing silly things to work around it that we know are safe:

  set(k: string, v: any): this & Fields {
    return (super.set(k, v): any);
  }

leebyron added a commit that referenced this issue Oct 19, 2017
Not ideal, but this adds dramatically better support for subclassing Records when using Flow. The cost of this is that the default values passed to Record are no longer checked - they should be separately checked :/. I've updated the documentation to show this in the example, as well as including an example of subclassing a Record when using Flow.

Fixes #1388
@leebyron
Copy link
Collaborator

@acusti - I just put up a PR that should make this considerably easier after brainstorming with some folks on the Flow team. Take a look at the examples in there and let me know if they look reasonable to you

leebyron added a commit that referenced this issue Oct 20, 2017
* Improved Flow support for Record subclasses

Not ideal, but this adds dramatically better support for subclassing Records when using Flow. The cost of this is that the default values passed to Record are no longer checked - they should be separately checked :/. I've updated the documentation to show this in the example, as well as including an example of subclassing a Record when using Flow.

Fixes #1388

* Update record.js
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

3 participants