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

Pull out inline enums as types in protocol.d.ts #216

Merged
merged 3 commits into from
Jun 8, 2020

Conversation

jackfranklin
Copy link
Contributor

@jackfranklin jackfranklin commented May 27, 2020

Note: this change is taken directly from this DevTools CL (we should
explore getting DevTools to depend on this module to avoid the
duplication): https://chromium-review.googlesource.com/c/devtools/devtools-frontend/+/2113374. I'm happy to take that on.

This PR takes the work Simon did from DevTools into this repo - although it's me committing this is all his work!

This change is motivated by moving Puppeteer to TypeScript and wanting
to control types more strictly rather than declaring arguments as
strings even though really only a subset are supported.

I've copied the commit message from the above here:

Commands, Events and Object types can declare "inline enums" to
restrict the possible values of a 'string' field.

Example field:

  referrerPolicy: ('unsafe-url'|'...'|'...')

To enable type-checking with TypeScript and stay compatible with
existing code, we now generate explicit enums.
for the enum names is adapted from code_generator_frontend.py
and needs to always match.

Example generated enum for the above code:

  export enum RequestReferrerPolicy {
    UnsafeUrl = 'unsafe-url',
    NoReferrerWhenDowngrade = 'no-referrer-when-downgrade',
    NoReferrer = 'no-referrer',
    Origin = 'origin',
    OriginWhenCrossOrigin = 'origin-when-cross-origin',
    SameOrigin = 'same-origin',
    StrictOrigin = 'strict-origin',
    StrictOriginWhenCrossOrigin = 'strict-origin-when-cross-origin',
  }

Note: this change is taken directly from this DevTools CL (we should
explore getting DevTools to depend on this module to avoid the
duplication): https://chromium-review.googlesource.com/c/devtools/devtools-frontend/+/2113374

This change is motivated by moving Puppeteer to TypeScript and wanting
to control types more strictly rather than declaring arguments as
strings even though really only a subset are supported.

I've copied the commit message from the above here:

Commands, Events and Object types can declare "inline enums" to
restrict the possible values of a 'string' field.

Example field:

  referrerPolicy: ('unsafe-url'|'...'|'...')

To enable type-checking with TypeScript and stay compatible with
existing code, we now generate explicit enums.
for the enum names is adapted from code_generator_frontend.py
and needs to always match.

Example generated enum for the above code:

  export enum RequestReferrerPolicy {
    UnsafeUrl = 'unsafe-url',
    NoReferrerWhenDowngrade = 'no-referrer-when-downgrade',
    NoReferrer = 'no-referrer',
    Origin = 'origin',
    OriginWhenCrossOrigin = 'origin-when-cross-origin',
    SameOrigin = 'same-origin',
    StrictOrigin = 'strict-origin',
    StrictOriginWhenCrossOrigin = 'strict-origin-when-cross-origin',
  }
Copy link
Contributor

@TimvdLippe TimvdLippe left a comment

Choose a reason for hiding this comment

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

Change LGTM. Before merging, can we verify that existing users of these types are not broken? Given that TS is structurally typed, I don't expect breakages, but I would like to double-check. If it does turn out to be a breaking change, we would need to up the major version number.

@jackfranklin
Copy link
Contributor Author

I'm not sure how best to check across everything. We know this is OK in Devtools as we use it there, and I don't think it was a breaking change there.

Maybe we could ask someone from Lighthouse (@connorjclark ?) to have a look at if this causes issues there.

@TimvdLippe
Copy link
Contributor

I think a simple check would be to create a dummy project and npm i this package. Then use a couple of types and use the literal values (e.g. 'xml'). Then copy your .d.ts file from this PR into your local project and see if it starts complaining or not.

@jackfranklin
Copy link
Contributor Author

This is a breaking change. I took this code:

import { Protocol } from 'devtools-protocol'

const message: Protocol.Console.ConsoleMessage = {
  source: 'xml',
  level: 'log',
  text: 'foo'
}

Which typechecks with the master branch of this repo. But with this PR it fails:

index.ts(4,3): error TS2322: Type '"xml"' is not assignable to type 'ConsoleMessageSource'.
index.ts(5,3): error TS2322: Type '"log"' is not assignable to type 'ConsoleMessageLevel'.

I think we have two options:

  1. Accept this is a breaking change and bump the version accordingly.
  2. Consider using union types, e.g. `type ConsoleMessageSource = 'xml' | '...' and then we wouldn't get the error we get above. There are pros and cons to using these over enums that we should consider.

@brendankenny
Copy link
Contributor

I'm not sure how best to check across everything. We know this is OK in Devtools as we use it there, and I don't think it was a breaking change there.

This does break Lighthouse, though only in a few places. I'm actually not entirely sure why it only does that and doesn't instead either break nothing or break all over the place. I'll try to figure that out :)

For future reference, testing is fairly easy:

git clone [email protected]:GoogleChrome/lighthouse.git && cd lighthouse
yarn add -D https://github.com/jackfranklin/devtools-protocol#2bb3bb0
yarn type-check

(sorry for the yarn requirement, but for some reason npm is failing on my machine for any variation on npm i -D github:jackfranklin/devtools-protocol#2bb3bb0)

@brendankenny
Copy link
Contributor

Oh, I was already too late :) That's exactly the issue for Lighthouse code.

Consider using union types, e.g. `type ConsoleMessageSource = 'xml' | '...' and then we wouldn't get the error we get above. There are pros and cons to using these over enums that we should consider.

this would be ideal for us. I was actually curious about the need to move to enums in particular since string unions also meet the need to "restrict the possible values of a 'string' field". Moving to named unions would be a transparent change that I assume would meet DevTools' needs?

@jackfranklin
Copy link
Contributor Author

jackfranklin commented May 27, 2020

In Puppeteer we've found the enum useful because we can also write JS to error if a user doesn't supply a correct value - e.g. like this: puppeteer/puppeteer@7eab7f8#diff-73da6513596563a004bf179729e919e1R131

It is useful to be able to refer to something as MyEnum.value and does add some clarity to code so I'm broadly in favour of using enums more than unions but that becomes a trade-off against breaking changes and then maybe it's not worth it?

I'm not sure in DevTools frontend what the motivation was for Enums over Union types - @TimvdLippe do you know? Or @szuend maybe?

@TimvdLippe
Copy link
Contributor

For DevTools, we needed references to these values in our type annotations. E.g. we have a function that accepts a particular enum value and we wanted to type it as-is. Before it, it would be solely string. The type reference requires a separate type to exist and thus necessitates the separate enum.

I think the solution of the union of the enum and the explicit value is a good first step. We could put that in a patch release and then announce to users they should update their code over time to use the enum. Then in a later major version, we can remove the explicit enum values from the inline enum.

@brendankenny
Copy link
Contributor

In Puppeteer we've found the enum useful because we can also write JS to error if a user doesn't supply a correct value - e.g. like this: puppeteer/puppeteer@7eab7f8#diff-73da6513596563a004bf179729e919e1R131

ah, yeah, enums are enumerable in ts but are just types (not values) in js :/

I'm surprised the

await this._client.send('Emulation.setEmulatedVisionDeficiency', {
  type: type || 'none',
});

lines don't run into the same issue as you mentioned in #216 (comment), though? Simplified version in the ts playground.

@jackfranklin
Copy link
Contributor Author

jackfranklin commented May 27, 2020

@brendankenny good catch, I would expect that to fail in Puppeteer...I'll dig into that!

Edit: it's because the typedef of that type in the params is the union type of 'none' | '...' so TS is happy. If this was typed under the hood as an enum, then you're right that that line would cause a compile error.

@jackfranklin
Copy link
Contributor Author

think the solution of the union of the enum and the explicit value is a good first step

@TimvdLippe do you mean that we should try to make it so we support either the enum form or a regular string and take either?

@TimvdLippe
Copy link
Contributor

Yes basically option 2 as you described it. That would not break existing users, allows users to use the enum right away and then in a major version upgrade we allow ourselves to remove the non-enum version. This should give our users the time to gradually upgrade, rather than a single big-bang upgrade to enums.

@jackfranklin
Copy link
Contributor Author

I don't think I was clear, my option two was to not use enums and only use union types. I hadn't considered that we could do both. I'm trying to figure out if we can even express that with TypeScript...will prototype.

@jackfranklin
Copy link
Contributor Author

So I think we could generate TS that looks like this:

        export enum ConsoleMessageSourceEnum {
            XML = 'xml',
            Javascript = 'javascript',
            Network = 'network',
            ConsoleAPI = 'console-api',
            Storage = 'storage',
            Appcache = 'appcache',
            Rendering = 'rendering',
            Security = 'security',
            Other = 'other',
            Deprecation = 'deprecation',
            Worker = 'worker',
        }

        export type ConsoleMessageSourceUnion = 'xml'| 'javascript'| 'network'| 'console-api'| 'storage'| 'appcache'| 'rendering'| 'security'| 'other'| 'deprecation'| 'worker'

        /**
         * Console message.
         */
        export interface ConsoleMessage {
            /**
             * Message source.
             */
            source: ConsoleMessageSourceEnum | ConsoleMessageSourceUnion;

Which might work here ? And we plan long term to remove the union support.

@brendankenny what are your thoughts?

@TimvdLippe
Copy link
Contributor

Oh right. Yes I mis read. Basically I was thinking doing this: Playground Link

@connor4312
Copy link

Because this is purely a .d.ts I think these should be declared as const enum so that they're compiled as strings in the code and not referenced concretely.

@jackfranklin
Copy link
Contributor Author

@connor4312 in the case of Puppeteer though we might want to reference them concretely - e.g. puppeteer/puppeteer@7eab7f8#diff-73da6513596563a004bf179729e919e1R131 - so I think not using const enum is what we need?

I'm by no means an expert on TS enums and the variants so I may well be way off here :D

@connor4312
Copy link

connor4312 commented May 27, 2020

With a normal enum, when you reference it in code like Foo.Bar, TS will assume there's JavaScript backing it up and try to import the corresponding compiled code at runtime. This works in puppeteer since you actually compile from .ts, which generates compiled code for the normal enum. But since this module ships only .d.ts, such an import will fail. Using a const enum, the enum value is replaced in the code at compile time. Repl.it example: https://repl.it/@ConnorPeet/RoastedAcrobaticRegister

@brendankenny
Copy link
Contributor

brendankenny commented May 28, 2020

So I think we could generate TS that looks like this:
...
Which might work here ? And we plan long term to remove the union support.

@brendankenny what are your thoughts?

I believe the enum/string-literal union would work for Lighthouse, but a long term plan to get rid of the string literal union would still break things. e.g. there's no javascript value that will satisfy a parameter or property of type ConsoleMessageSourceEnum.

I wonder if it would be possible instead to

  • create the enums for their usability as types and values in typescript code
  • but keep the string literal union as the type of the protocol types since enum values are assignable to string literals. e.g.
enum ConsoleMessageLevel {
  Log = 'log',
  Warning = 'warning',
}
const lvl1: ConsoleMessageLevel.Log = 'log'; // Error: Type '"log"' is not assignable to type 'ConsoleMessageLevel.Log'.
const lvl2: 'log' = ConsoleMessageLevel.Log; // Good to go.

and everything is still strictly type checked.

Here is @TimvdLippe's playground example but with the enums removed from the source and level properties. Types are still checked on message and message2 whether assigned a string literal or an enum value, and the compiled output still has the enums as real JS values (so Object.keys() etc will still work).

@szuend
Copy link

szuend commented May 28, 2020

The reason we introduced it in the first place, is because the enums types are currently used in the existing DevTools JSDoc. Assuming we don't want to diverge the generated .d.ts in DevTools and puppeteer, we need to make sure that whatever we come up with, works with the existing DevTools code (meaning we need to make closure happy).

@TimvdLippe
Copy link
Contributor

@jackfranklin LGTM!

@jackfranklin
Copy link
Contributor Author

@szuend that's a good point. I'd be happy to maintain our own version in DevTools frontend until such time where Closure is less of an issue, but we are talking a long time frame. @TimvdLippe WDYT?

My hunch is that this approach would work with Closure because we still have the enum types that we can reference in Closure land but I'm not 100%.

@brendankenny are you happy with the code as it is in that TypeScript Playground link above?

@TimvdLippe
Copy link
Contributor

Yes we are a far way off to getting rid of Closure. Once we are in TS-only land, I want to use this package and remove our custom integration.

The only thing we need to figure out is the Dispatcher work. E.g. the on('event-name') vs an interface with properly defined methods. That would be a breaking change here, so I am not sure what the best solution is. Let's defer that to when we are in a position to make that change.

@brendankenny
Copy link
Contributor

@brendankenny are you happy with the code as it is in that TypeScript Playground link above?

Yes, LGTM!

@jackfranklin
Copy link
Contributor Author

@TimvdLippe please could you have a look at the latest commit?

This avoids a breaking change for existing consumers whilst new
consumers can still pass in enum values and typecheck. E.g. both of
these work:

```
const message: ConsoleMessage = {
  source: 'xml',
  level: 'log',
  text: 'foo'
}
```

```
const message2: ConsoleMessage = {
  source: ConsoleMessageSource.XML,
  level: ConsoleMessageLevel.Log,
  text: 'foo'
}
```
Copy link
Contributor

@TimvdLippe TimvdLippe left a comment

Choose a reason for hiding this comment

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

LGTM

@TimvdLippe TimvdLippe merged commit bca028b into ChromeDevTools:master Jun 8, 2020
@jackfranklin
Copy link
Contributor Author

@brendankenny do you know how we go about publishing this change on npm? Thanks :)

@TimvdLippe
Copy link
Contributor

This will be published automatically by a script. So it is soonTM.

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

Successfully merging this pull request may close these issues.

5 participants