Skip to content
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

Improved Flow Types for babel/types #7

Closed
wants to merge 5 commits into from

Conversation

MichaReiser
Copy link

@MichaReiser MichaReiser commented Oct 2, 2020

facebook-github-bot pushed a commit to facebook/metro that referenced this pull request Oct 8, 2020
Summary:
This diff adds a more complete declaration file for `babel/types` and parts of `NodePath` from `babel/traverse`.

## Motivation

* Get better intelli-sense
* Unleash flow's super power and allow it to catch errors at compile time. This will also allow us to catch not yet handled cases after upgrading `babel`. E.g. the support for `SpreadElements`.
* More explicit types in our APIs that are part of Metro's documentation
* Delete repetitive and incomplete/incorrect typings. E.g the code base has multiple incomplete definitions of the Visitor interface

## Typing

### `babel/types`
This diff uses the `babel/types` proposed in the [RFC](babel/rfcs#7) submitted to the babel repository. The PR hasn't been reviewed nor accepted. It's, therefore, likely that further changes are needed.

We can adopt to any changes, if any, on our own paces since we have a custom `babel/types` declaration file today which we'll hopefully able to delete once the RFC has been accepted and the PR's merged and released. We also have the option to use our own fork of the typing generation script if needed.

### `babel/traverse`

There are no official nor in-offical existing types for `babel/traverse` today. My goal is to submit our types to flow-typed once the RFC is merged and the types are more complete. The definitions are based on the Typescript definitions.

## Shortcomings

### No `%checks` support on methods

Flow doesn't support `%checks` refinements on object methods. That also applies if a function is invoked on a method, e.g. `t.isIdentifier(node)` will not refine `node` to an `Identifier`.

This requires us to destructure the `is*` methods to get type refinement.

```js
if (t.isIdentifier(node)) {
 // ^^ Doesn't refine because the function invoked as a method
  console.log(node.name);
}

const {isIdentifier} = t;

if (isIdentifier(node)) {
  console.log(node.name); // works fine
}
```

### `NodePath` Refinement

Flow doesn't support `%checks` refinements on methods as mentioned in the previous section. That means that the `NodePath.is*` methods cannot be correctly typed to refine `NodePath` to a more specific type.

It's further not possible to refine a `NodePath` by checking on the type of the node. E.g.

```
function test(path: NodePath<Node>): void {
  if (path.node.type === "Identifier") {
    const identifier: Identifier = path.node; // <--- works
   const identifierPath: NodePath<Identifier> = path; <-- Doesn't work.
  }
}
```

See [Post](https://fb.workplace.com/groups/flow/permalink/4458059784242602/) for the reasons.

This is unfortunate because there's no way to cast `NodePath<T>` to `NodePath<S>` (to my knowledge) except casting over any which is unsafe. There were only a few places in the code base today where this was needed. I'll try to rethink this when working on a more complete typing for `babel/traverse`.

### `NodePath.node` is marked read-only

I marked `NodePath.node` as read-only such that a more specific `NodePath` can be assigned to a more generic `NodePath`. E.g. that the following assignment is supported

```
function getPathKey(path: NodePath<>): string {
  return path.key;
}

function test(path: NodePath<CallExpression>): void {
  const key = getPathKey(path);
                         ^^^^^
               //  Casts NodePath<CallExpression> to NodePath<>
}
```

However, the `node` isn't read-only in reality, e.g. it can be replaced using `NodePath.replaceWith(somethingElse)`.

A sound definition of `NodePath` would define `node` as non-generic. However, that makes it extremelly cumbersome to work with `Node` e.g. in visitors because `invariant` checks would been needed in all visitor method if the accepted node is of the expected type so that flow refines the node (Flow would invalidate the refinement after every function / method calls which will make it nearly impossible).

I believe that this unsoundness is acceptable and it's still a huge improvement over not having any types at all.

### `NodePath.get` isn't generic

`NodePath.get` is an interesting method because it returns either a `NodePath<TNode>` or `Array<NodePath<TNode>>` depending on whatever the field of the passed in key is an array type or not.

It's, unfortunately, not possible to type this method as generic today because of a [bug in flow](https://fb.workplace.com/groups/flow/permalink/4460750363973544/). This requires some additional invariants in the code to help flow understand what's going on.

## Summary

I believe that the added types will help us to catch bugs when writing new code and in the existing code base. It will further help us to know what new cases we need to handle when upgrading Babel to a new version. Having better intellisense further helps during development (e.g. I spend hours wondering why `template()` produced incorrect code when I passed a string placeholder instead of a node.

However, it comes at the cost that `babel/types` cannot be exactly typed which means that some additional invariants are needed to make flow happy and it can sometimes mean that we'll have to cast over `any`.  I believe that this additional cost is worth taking in regards of what the additional typing gives us in return.

Reviewed By: cpojer

Differential Revision: D24117447

fbshipit-source-id: d00440bfeaccd827b2cb412e50bd17d54f96636d
Copy link
Member

@nicolo-ribaudo nicolo-ribaudo left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems to me that the major problem that this RFC is addressing is that %check refinements only work for bare function calls and not for methods, so these things don't work:

types.isIdentifier(node);
path.isIdentifier();

While looking at this problem in the past, I found facebook/flow#7735 which makes Flow do the correct thing without us needing to modify our types to workaround this missing feature.


# Drawbacks

* Braking Change: Applications that use the existing flow types and rely on the `types.is*` methods would have to migrate to `node.type ===` checks.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a big usability regression. While it's easy to replace things like types.isIdentifier(), manually inlining functions like types.isExpression() or types.isLoop() is a mess.

I wouldn't be comfortable with dropping these %checks utilities.

Copy link
Author

@MichaReiser MichaReiser Nov 5, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree that this is a regression and I don't know how widely %checks is used in the wild. I want to clarify that writing out the %checks predicate is only allowed for function declaration (declare function) but not when defining a function. Flow automatically infers the predicate if it's a function definition. Function declaration are mostly used in declaration files.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, %checks with .type works perfectly. That's what this PR is making use of. The only thing where it gets messy, as you pointed out, is that writing declare function isLiteral(t) % checks t.type === 'StringLiteral' || t.type === 'NumericLiteral' ... is somewhat lengthy. I first thought that it was possible in the past to write declare function isLiteralI(t) %checks t instanceof BabelNodeLiteral but that wasn't the case since there was no BabelNodeLiteral type.

So the only regressions are that manual %checks must be rewritten and that the node types are no longer exported as value which has the implications that:

  • Nodes must be imported using import type,
  • Node types must be extended using the spread operator instead of extends (should be a very rare use case)
  • Any other value reference to node types is (correctly) illegal

Copy link
Member

@nicolo-ribaudo nicolo-ribaudo Nov 10, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As far as I understand, this RFC doesn't mention that we can still use %checks with type. It says that it isn't possible to still use the is* methods for type refinements:

Braking Change: Applications that use the existing flow types and rely on the types.is* methods would have to migrate to node.type === checks.

I see that your PR already keeps the %checks in the generated type annotations: could you update this RFC text to match that behavior? You can also write that this is an improvement, since things like isPattern() will now help refining.

PS. You can add me as a champion in the RFC header.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh sorry. My fault. I'll update the RFC. It's a relic from an earlier version of the RFC.


# Open questions

* Should the global `BabelNode*` exports be replaced by module level exports without the `BabelNode` prefix to better align with the type script types or should the definition file export both until the next major version.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can both use the global BabelNode* version and normal named exports for now, and then remove the global version in Babel 8.

@MichaReiser
Copy link
Author

Thanks @nicolo-ribaudo for looking into this RFC. The main motivation is to create a typing that allows a natural usage of the babel-types. E.g. I would expect that flow correctly refines a node to a string literal when writing node.type === 'StringLiteral' or isStringLiteral(node). The former is only possible with this proposed PR and doesn't work with the current type definition because BabelNode isn't declared as a union.

The second motivation was to remove unsoundness from the type definition. E.g. two examples

  • node instanceof BinaryExpression will typecheck without errors because the typings define a class BinaryExpression but fails at runtime because because there's no value BinaryExpression exported by babel-types.
  • Assigning a manually constructed node won't typecheck correctly because it isn't implementing the required class. E.g. const str: BabelNodeStringLiteral = { type: "StringLiteral", value: "test", ...}; Flow would force me to use the StringLiteral constructor functions which, however, doesn't exist. I'm aware that the proposed way is to use t.stringLiteral but it's unsound.

I further believe that we shouldn't wait for a stale Flow PR (no update in 1.5 years) to improve the devx for people working with babel (I've a local version of babel-traverse).

@mischnic
Copy link

mischnic commented Nov 5, 2020

AFAICT, path.isIdentifier() isn't possible even with that Flow PR

no update in 1.5 years

I've rebased that PR, let's see what happens... facebook/flow#8525

@MichaReiser
Copy link
Author

thank you @mischnic for rebasing the PR.

Support for some for of refinements for NodePath is crucial to successfully type babel-traverse and work with it without going insane (e.g. having to cast over any all the time).

The proposed PR allows to refine NodePaths by using path.node.type === "Identifier" whereas the current typing doesn't support NodePath refinements (because of the flow limitation)

Comment on lines +150 to +152
## Create a transform script

Create a babel plugin that rewrites the `%checks` refinements and changes the imports of node types from values to types.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think that this will be need in practice. Personally I expect that the number of people manually writing %checks for Babel types in custom functions is close to zero, and I would consider this PR strictly as a bugfix (i.e. mark nodes not as classes).

Code like this is invalid in flow:

function check(x: A | B): %checks (x instanceof A) {
    return x.type === "a";
}

repl

@MichaReiser
Copy link
Author

Any updates on this? Typing the assert* functions should also be possible now with Flow 0.140 that supports non boolean %check functions.

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

Successfully merging this pull request may close these issues.

3 participants