8000 Add typings for tapable by HerringtonDarkholme · Pull Request #39 · webpack/tapable · GitHub
[go: up one dir, main page]
More Web Proxy on the site http://driver.im/
Skip to content

Add typings for tapable #39

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

Conversation

HerringtonDarkholme
Copy link
@HerringtonDarkholme HerringtonDarkholme commented Nov 6, 2017

This pull request is for typing tapable's new API.
The typing is shipped within the package of tapable, rather than on DT. This is better in terms of developer experience since users only need to install one package.

The typing is summarized from both README and source code. If something is wrong, please tell me!

For implementation detail, I'm using generic default to provide sensible type inference and moderate checking. Tapable's new API uses variadic arguments a lot, which is hard to model in type system. (Not that hard if microsoft/TypeScript#5453 is implemented).

I have to pack more type parameters into one class in order to support multiple argument types. So users can write const hook = new SyncHook<string>(["param"]), and hook.call('param') will be correctly typed. On the other hand, passing more arguments won't trigger compiling error, so callback function can be used.

We can, however, make the export API as function instead of class. TtypeScript/FlowType can support overloaded function, so we can add to one hook more signatures that return different types respectively.

cc @TheLarkInn

@jsf-clabot
Copy link
jsf-clabot commented Nov 6, 2017

CLA assistant check
All committers have signed the CLA.

@codecov
Copy link
codecov bot commented Nov 6, 2017

Codecov Report

Merging #39 into master will not change coverage.
The diff coverage is n/a.

Impacted file tree graph

@@           Coverage Diff           @@
##           master      #39   +/-   ##
=======================================
  Coverage   91.04%   91.04%           
=======================================
  Files          14       14           
  Lines         413      413           
  Branches       73       73           
=======================================
  Hits          376      376           
  Misses         34       34           
  Partials        3        3

Continue to review full report at Codecov.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update cdb9798...3b6286a. Read the comment docs.

@TheLarkInn
Copy link
Member

@HerringtonDarkholme This looks so great thank you!!!!

I have a question for you. Would we solve the variadic issue if we instead passed an object with those parameters, and to make it easy to pull into the new Function() and serialize the args. So the airity could be solved and we could use object destructuring.

cc @sokra

@HerringtonDarkholme
Copy link
Author
HerringtonDarkholme commented Nov 7, 2017

@TheLarkInn Yes, it will.
Example

// use generic annotation
const hook = new SyncHook<{source: string, target: string, routeList: List<Route>}>()
// or use runtime object as declaration 
// const hook = new SyncHook({ source: String, target: String, routeList: List })

hook.call({source, target, routeList}) // checked against the type declared above
hook.tap("MapPlugin", ({source, target, routeList}) => { // argument type inferred
    const route = maps.findRoute(srouce,target)
   routeList.add(route)
})

Everything checked.

@sokra
Copy link
Member
sokra commented Nov 7, 2017

I see the problem.

As the current TypeScript lacks generic overloads you could do it like this:

type AsyncHook2<A, B> = {
    tap(name: string, fn: (a: A, b: B) => void) : void;
    tapAsync(name: string, fn: (a: A, b: B, callback: (err?: Error) => void) => void) : void;
    tapPromise(name: string, fn: (a: A, b: B) => Promise<void>) : void;
    callAsync(a: A, b: B, callback: (err?: Error) => void): void
    promise(a: A, b: B): Promise<void>;
}

type AsyncHook1<A> = {
    tap(name: string, fn: (a: A) => void) : void;
    tapAsync(name: string, fn: (a: A, callback: (err?: Error) => void) => void) : void;
    tapPromise(name: string, fn: (a: A) => Promise<void>) : void;
    callAsync(a: A, callback: (err?: Error) => void): void
    promise(a: A): Promise<void>;
}

As this lacks argument names 😢 it would be cool if TypeScript supports this:

type AsyncHook<T> {
    tap(fn: ([P in keyof T]: T[P]) => void): void
    tapAsync(fn: ([P in keyof T]: T[P], callback: (err?: Error) => void) => void): void
    tapPromise(fn: ([P in keyof T]: T[P]) => Promise<void>): void
    callAsync([P in keyof T]: T[P], callback: (err?: Error) => void): void;
    promise([P in keyof T]: T[P]): Promise<void>;
}

x = {} as AsyncHook<{source: string, target: string, routeList: List<Route>}>

Maybe we propose this syntax. Should be easier to implement than variadic generics

@HerringtonDarkholme
Copy link
Author
HerringtonDarkholme commented Nov 7, 2017

@sokra Thanks for the reply.

However, I don't think ([P in keyof T]: T[P]) => void will be clear, since key iteration isn't ordered. Neither runtime nor compile time. But function call does need arguments be ordered!

I would still vote for variadic generics.

@TheLarkInn
Copy link
Member

@HerringtonDarkholme I had not realized it when I originally mentioned the object syntax as an alternative but @sokra reminded me that in this case, object creation would be pretty costly and slow (and given that this library is passing around a bunch of these in the 10s-100s of thousands of times, it potentially could add up. Maybe there is something I can key in @DanielRosenwasser about

@DanielRosenwasser
Copy link

I was hoping object allocation wouldn't be a problem. I guess it is... 😅

@sokra
Copy link
Member
sokra commented Nov 8, 2017

since key iteration isn't ordered.

It's officially ordered since es2015.

I would still vote for variadic generics.

Fine for me too. Whatever solves the issue.

@colelawrence
Copy link
colelawrence commented Nov 29, 2017

You could also use declarations for automatically inferring the variadic arguments. I personally think that this would be a really elegant solution that doesn't change much of the implementation details.

This is based off how the TypeScript team implemented their DOM EventMap types and the ideas sokra mentioned.

type AsyncHook3<A, B, C> = {
  tap(name: string, fn: (a: A, b: B, c: C) => void) : void;
  tapAsync(name: string, fn: (a: A, b: B, c: C, callback: (err?: Error) => void) => void) : void;
  tapPromise(name: string, fn: (a: A, b: B, c: C) => Promise<void>) : void;
  callAsync(a: A, b: B, c: C, callback: (err?: Error) => void): void
  promise(a: A, b: B, c: C): Promise<void>;
}

type AsyncHook2<A, B> = {
  tap(name: string, fn: (a: A, b: B) => void) : void;
  tapAsync(name: string, fn: (a: A, b: B, callback: (err?: Error) => void) => void) : void;
  tapPromise(name: string, fn: (a: A, b: B) => Promise<void>) : void;
  callAsync(a: A, b: B, callback: (err?: Error) => void): void
  promise(a: A, b: B): Promise<void>;
}

type AsyncHook1<A> = {
  tap(name: string, fn: (a: A) => void) : void;
  tapAsync(name: string, fn: (a: A, callback: (err?: Error) => void) => void) : void;
  tapPromise(name: string, fn: (a: A) => Promise<void>) : void;
  callAsync(a: A, callback: (err?: Error) => void): void
  promise(a: A): Promise<void>;
}

// the secret sauce
declare function asyncHook<A>(args?: [string]): AsyncHook1<A>;
declare function asyncHook<A, B>(args?: [string, string]): AsyncHook2<A, B>;
declare function asyncHook<A, B, C>(args?: [string, string, string]): AsyncHook3<A, B, C>;

asyncHook<number>(["count"]).callAsync(100, function () {})
// same thing wrong types
asyncHook<number>(["count"]).callAsync("100", function () {})

asyncHook<number, {delta: number}>(["count", "timing"])
  .callAsync(100, {delta: 100}, function () {})

// wrong number of names
asyncHook<number, string, number[]>(["number", "name"])

// right number of names
const async4 = asyncHook<number, string, number[]>(["count", "name", "tries"]);

// wrong number of args
async4.callAsync(100, "percent", function () {})
// right number of args
async4.callAsync(100, "percent", [100], function () {})

For each of the comments labeled "wrong," TypeScript will throw compiler errors.

@TheLarkInn
Copy link
Member

@sokra, @HerringtonDarkholme What are your thoughts here on @colelawrence 's idea. It feels like this might be suitable to me.

@HerringtonDarkholme
Copy link
Author
HerringtonDarkholme commented Dec 8, 2017

Overloading is a good solution. Current implementation is using new, in TypeScript constructor cannot be overloaded. If we can change it to pure function we can adopt the new typing. cc @sokra

@sokra
Copy link
Member
sokra commented Dec 11, 2017

Why can new not be overloaded?

Can we do something like this?

this.hooks = {
  /** @type {AsyncHook1<Compiler>} */
  myHook: new AsyncHook(["compiler"])
};

@HerringtonDarkholme
Copy link
Author

Why can new not be overloaded?

Sorry, let me clarify it. new is overloadable in terms of argument types, but it cannot be overloaded to return different type.

Currently Hook has only one type constructor which has eight type parameter. So we cannot specify how many arguments call or tap will receive.

To specify the concise arity of call / tap, we need to have several type constructors with different number of type arguments.

So @colelawrence uses a different approach: overload one plain function to return different types e.g. AsyncHook1 / AsyncHook2 ...

@colelawrence
Copy link
colelawrence commented Dec 11, 2017 via email

@colelawrence
Copy link

With a little fiddling, I think I found a way for us to do the same thing with new.

// the secret sauce
interface AsyncHookable {
  new <A> (args?: [string]): AsyncHook1<A>
  new <A, B>(args?: [string, string]): AsyncHook2<A, B>
  new <A, B, C>(args?: [string, string, string]): AsyncHook3<A, B, C>
}
// the magic
declare const AsyncHook: AsyncHookable

let a1 = new AsyncHook(["hello"])

new AsyncHook<number>(["hello"]).callAsync(100, function () {})
new AsyncHook<number, {time: number}>(["hello", "timing"])
  .callAsync(100, {time: 100}, function () {})

// wrong number of names
new AsyncHook<number, string, number[]>(["number", "name"])

// right number of names
const async4 = new AsyncHook<number, string, number[]>(["number", "name", "number"]);

// wrong number of args
async4.callAsync(100, "hi", function () {})
// right number of args
async4.callAsync(100, "hi", [100], function () {})

// same thing wrong types
new AsyncHook<string>(["hello"]).callAsync(100, function () {})

Here, I want to demonstrate that we can "overload" the new constructor, and we do not have to declare a class (because you cannot overload return types of the new constructor inside of a class).
The only way I could think of a way to do this was to overload the new constructor with an interface, and then cast that interface to a const. The problem here might be that we cannot actually cast the AsyncHookable interface to any one class because it would be impossible to implement since new keywords need to return all the same types or void.

I'm thinking out loud a little bit, here.

@DanielRosenwasser
Copy link
DanielRosenwasser commented Dec 12, 2017

Construct signatures can be overloaded, but you can't use extends with a constructor function that doesn't construct the same type in each overload. You also can't write constructor overloads with distinct return types.

So the problem is really that you can describe some of these things to TypeScript, but you can't extends from these types and you can't easily implement them.

@sokra sokra changed the title Add typgins for tapable Add typings for tapable Dec 12, 2017
@sokra
Copy link
Member
sokra commented Dec 12, 2017

@colelawrence Your approach looks great.

@TheLarkInn
Copy link
Member

@HerringtonDarkholme Would you be willing to make the following suggestions from @colelawrence 's feedback?

Looking forward to land this :-D

@HerringtonDarkholme
Copy link
Author

I just noticed tapable has new documentation shipped and I have read it.
It turns out that I was too optimistic about guessing API meaning of tapable. 😭
I missed a lot of semantic information in the first typing declaration. For example, TapInfo has more properties than typing declaration. More complicated case is that different Hook class has subtle difference(in tap, I guess).

I also tried to read the source code and test case. But it turns out compiling is hard to grok...

I believe @colelawrence 's approach is feasible. However I think it should be better if some one more familiar with tapable can add the typing.

@TheLarkInn
Copy link
Member

No problems at all. @colelawrence are you open to PR?

@colelawrence
Copy link
colelawrence commented Dec 22, 2017 via email

@HerringtonDarkholme
Copy link
Author

Thank @colelawrence for taking over this.

Sorry about not shipping this. I got quite busy these days so I cannot follow this. Closing.

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.

6 participants
0