-
-
Notifications
You must be signed in to change notification settings - Fork 15.3k
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
Rewrite compose
utility function
#3568
Rewrite compose
utility function
#3568
Conversation
Deploy preview for redux-docs ready! Built with commit 89c11f3 |
Things worth thinking about possibly changing:
I had to add these overloads because various tests were passing in functions, other than the right-most function, which took in multiple params. This behavior was allowed previously due to the types (and I've maintained that behavior in this refactor), but it actually goes against what the JSDoc comment description said:
Anyway, these are just some thoughts I had while/after implementing the changes for this PR. I know most of the things mentioned above are a result of code that was written before any of the current maintainers were maintaining the project, but, considering you're already rewriting in TypeScript and will have some breaking changes anyway, I think it's worth thinking about some of these things and possibly making some changes in an effort to simplify behavior & make things easier to statically type. |
Thanks for filing this! To answer your last question briefly, Redux's const composedEnhancer = compose(
applyMiddleware(thunk, logger),
batch(),
devTools()
);
const composedComponent = compose(
connect(mapState, mapDispatch),
intl(),
)(MyComponent); There's no need for varying argument orders here. Specifically to the "only one argument" question: there's a lot of Redux-using code out there that is calling const composeEnhancers =
window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ ?
window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__({}) : compose;
const enhancer = composeEnhancers(
applyMiddleware(thunk),
); Note that the DevTools Extension So yes, we do indeed want to keep the 1-arg case. Agreed that the 0-arg case is kinda silly, though. Not sure on your other questions. I'm sure there's prior discussion wayyyyyy back in the issues/PRs history that might be relevant. |
Note: The build is currently broken due to this line:
This will need to be fixed. I think part of the problem is related to other types in this file, but there's also the fact that |
Hmm. Can it be made generic for an arbitrary number of functions? Realistically I don't expect too many, but that was part of the genesis for pulling in |
@markerikson I'm not sure I follow about the reading order matching the nesting, but I can see why a left-to-right version or eliminating the 1-arg case would complicate things since the dev tools' About my other suggestions: I think the most important one is the first about not allowing the non-right-most functions to take more than one argument. I can't think of a good reason for allowing this, since the implementation ignores them anyway. I think it would be best to just not allow people to pass these kinds of functions to |
I'll look into it. I also haven't played with |
Yeah, lemme phrase it this way: there's a ton of existing code that we don't want to break, Agreed that multi-args probably aren't relevant, but I don't know how we'd prevent that at the JS level (and not entirely sure how you'd do it at the TS level). Not terribly critical. |
Okay, @markerikson I updated the PR with a new overload based on what @jedmao was doing in the other PR, but without using However, I didn't remove any of the other overloads, as I feel like this should be a last resort sort of solution. Typically, you'd still want the help of the inference of the other overloads. This is really only needed for that one spot where Also, we could raise the number of functions from up to 5 to up to 10 to cover more ground, if you think it's necessary? It obviously adds a good bit of length to the definition, but it's fairly mechanical. |
I am unclear how this is better than what we have now - the point of the rewrite was to remove the overloads, this just replaces them? Can you explain what makes this better than the existing implementation? I am sure you already have, but I was not able to parse it out from the thread above, and perhaps a simple summary would help me |
If we're going to have this generic type signature anyway, why not delete all the others? export default function compose<Fns extends Fn[]>(...funcs: Fns): Composed<Fns> |
I'm inclined to agree with Greg. The original genesis of the question was if there was a way to handle an arbitrary number of arguments, generically, without hand-specifying specific numbers of parameters in a copy-paste manner. At the moment, this just looks like it rewrites the manual overloads, and doesn't simplify / replace them. |
@OliverJAsh pointed to his https://github.com/unsplash/pipe-ts However, that one also has hardcoded overloads, although it goes up to 9. (but why not 11? :)) He thinks that an arbitrary implementation isn't possible atm: https://twitter.com/OliverJAsh/status/1170753063709224961 also, apparently it's a left-to-right impl, so nm anyway :) |
Like I said, this should be a last resort. It doesn't provide the same inference as the overloads because of the nature of how it works. All it does is create the return type of the composed function by getting the params of the right-most function and the return type the left-most function:
This says nothing about the types of the functions you're passing in though. You basically lose all inference help for functions in between the first and the last and now must manually annotate everything. In this PR, try commenting out all of the overloads except for the generic one you suggested & the base case with the implementation body. Then, in the tests, find a This makes sense, because the generic implementation Also, there are problems with the old overloads. I called out an example in the original post: For example, in this test: Another thing to try... Underneath the above mentioned test, try adding this line:
You will get a type error because there is no overload that properly infers arguments without manually/explicitly providing generics for this case. This isn't true with the overloads in my PR. They are able to provide correct inference without explicitly annotating for many cases. I think people are looking for an easy, simple solution where one doesn't exist. Thoroughly typing a generic, variadic version of This is why libraries like Yes, requiring a ton of manually written overloads sucks, I know, but, unfortunately, as far as I know, it's the only way to really make things work the way they should in TS. |
If it's possible, it would be better to use mapped tuple spread types so we can make this type safe and homomorphic. |
That's what I thought I was suggesting in the other thread when I first brought this up :) |
Hmm... I think I would need to see an example. I'm not tracking what you mean. Don't get me wrong, I would love if someone were able to solve this problem without inference problems & tons of overloads, but I haven't seen it yet. |
To be clear, the ts-toolbelt actually uses like 40 overloads under the hood, so hiding that in a separate library does make the code more readable, FWIW. |
I checked the types from Underscore and Ramda, and they don't do this. As far as I can tell, this is not possible with TypeScript yet. I can do the following the a fixed number of arguments to homomorphically detect the return type in TypeScript 3.4, but since spread types aren't Turing complete I can't generalize this to variable arguments without breaking the homomorpism: function compose<A, B, C>(f: (arg: A) => B, g: (arg: B) => C): (arg: A) => C {
return x => g(f(x));
} Source: https://www.typescriptlang.org/docs/handbook/release-notes/typescript-3-4.html |
export type Compose<Fns extends Function[], mode extends Mode = 'sync'> = {
'sync' : (...args: Parameters<Last<Fns>>) => Return<Head<Fns>>
}[mode] |
@nickmccurdy Yes, that's what I was saying. It loses type information/there is no inference for the function args. It only knows how to create the return type, but this requires you to annotate all functions, and leaves open the possibility for masked mistakes.
@jedmao What do you mean? I'm just reiterating the things I've already said in previous comments, but just to be extra sure, I just created a new branch, installed I went ahead and modified You can find the branch here:
Test file with modifications & comments showing the problems with this approach: This is why using explicit overloads is better, IMO. It gives you much stronger inference, and, thus, is much safer. This generic definition is not safe on its own. It only really makes sense as a last resort overload to fall back on when no other overloads can be used to try to give the user a small amount of type information, because that's better than nothing (I suppose). |
Perhaps we aren't using https://github.com/pirix-gh/ts-toolbelt/blob/master/src/Function/Compose.ts#L45 This code clearly infers the returns and parameter types of every intermediate function. I have to run, but I will write more later |
I think you're right. Sorry, I didn't read those comments before. I see they are using mapped types to try to infer more information via the tuple's indexes. That looks interesting.
I don't think that's true. You can see in the example that they annotated all of those functions manually. I updated with a new branch using the F.Composer + F.Compose approach like the example in the comment there showed. This broke the 3 cases in the implementation (0 args, 1 arg, > 1 args), and I don't have time to fix them right now, so I just asserted them as New branch here:
Test file: I had to update the test file to change some functions, as this way of defining However, it looks like it's still unable to properly propagate types through the function args, as you can see the things that were |
Explicit overloads are not homomorphic. I think we’re better off with TS-toolbelt’s compose function. |
OK, I did some experimenting, you can see the results here. Note that the copy-pasta to make this work is pretty insane (first of all). Also, note the pure simplicity of the compose function, no types need be defined at all. Second, it demonstrates that in fact the types are correctly inferred for intermediate functions that explicitly define their types. However, I think I see the disconnect now. This definition of compose expects the intermediate functions to explicitly declare their parameter types. The redux implementation has been more strict, requiring all of the intermediate functions to have the same signature (all middlewares, for instance, or all store enhancers). This then raises a simple question: is this bad? What is the issue with requiring middlewares and enhancers to be explicitly typed? |
I assume when people need to compose a set of general functions, their first thought isn't to reach for Redux. But perhaps we can be explicit about this by renaming the function to |
I think people were (are?) misunderstanding the things I wrote for some reason. We are talking about two different things here. You're saying that it's able to infer the correct return type for The reason why I'm advocating for overloads is specifically because the types of the functions being passed to
I think so, yes, because it could provide much better inference. The issue isn't whether or not explicitly typing things is bad. It's whether or not the function can allow you to write the intermediate function arguments inline without explicitly annotating every one of them, which is very much possible with a different implementation, like what's in this PR. In my opinion, disregarding this just to try to achieve an elegant one type signature solution that doesn't cover all cases and has worse type inference is a bad tradeoff. If you all disagree with that, then there's nothing more I can say, but I think that is contrary to what most people using TypeScript want & expect. |
I agree. If we can provide better inference and not swallow types along the way, even if that means an insane number of overloads, the user experience should be the most important goal here. My ts-toolbelt PR was meant to do both, but if this PR identified and fixed some holes, I'm all for it! |
This makes sense to me Josh, thanks for the extra clarity. I think the key difference is in assumptions. I am assuming that our If we assume it should be general purpose, then my assumptions break down. A larger problem is the definition of In any case, I think we are getting closer to a solution, even if we ultimately decide not to touch the existing definitions. Understanding the beast we are up against is equally important. The key takeaway I have from all of this is that typescript really needs to be improved drastically before it can truly handle the expressiveness JavaScript supports in a clear way, and that is a rather large asterisk on the statement "Typescript provides type safety for JavaScript" that we should all be cognizant of. Now, before I get too philosophical, I wonder if we can take a step back and answer a couple of basic questions:
I have answers but rather than bias the results I would like to hear from others first. If, for example, we want a maximum number of middlewares or of store enhancers to support, that would create a limit we can work with. Or, if we expect all params to be the same signature, that also gives us a useful limit. Some of this is implicit (all middlewares should have same sig) but let's make it explicit. Then, we should design types that support these limits. |
We could also introduce two separate compose functions that are meant for either scenario, which might allow more freedom in how each is typed. |
The other common use case I've seen is composing HOCs. |
I think React Redux should provide its own compose for that, then. That would let whomever is writing the typings for React Redux also avoid this mess, because an HoC has a specific and consistent type ( |
I'm guilty of using I don't really have a strong opinion on the issue topic as a whole, but at least to me it's made sense to just reach for it as a general-purpose |
Honestly, I think compose should be an external dependency from Redux, but all the compose functions on npm seem to be not what we want, let alone typed properly. Everyone seems to have a different idea about the implementation of compose. Looks like they use promises pretty commonly too. |
It's part of our current API, everyone depends on it, and we're not going to remove it for v5. |
Why not? It's the perfect time to make a breaking change. |
I'd recommend deprecating it for a while before removing it |
or (sorry to not have thought of this before I hit send), re-export it from somewhere else and deprecate it. Make the external dep a peer dep, so that when it is finally removed, the user still has the peer dep |
I strongly oppose removing it. The compose function we have works. It's just a question of the best way to type it. There's no reason to remove it, and we would break tens of thousands of applications by removing this function. I'm fine with fiddling with type declarations for v5, but the point of the conversion was that we would not change any actual JS functionality. |
To be honest, I don't think we have enough TypeScript contributors to keep the compose types up to date at the level that it should be, especially considering that new TypeScript will add advanced new type features that we should adapt to. Compose should be removed so we can get the best types from another library. Simply adding types to our existing JS code isn't enough, TypeScript won't be able to infer or verify the types. |
I'm not saying remove the function itself, just rename it to its specific use ( |
OK. Here's what I propose: we do nothing. The types, as they are, work fine. The only goal here was to improve them. AFAIK, no one has had trouble with the existing types. Perhaps later Typescript will make this easy. Until then, I don't see any obvious benefits to changing things, and many disadvantages. |
I know for certain there are people importing This conversation has surely turned into a 🐇 🕳 , so I'm with @cellog. I'll be closing my PRs. |
We should delete compose to let it be maintained by other packages, then Redux users can install the other packages and non-Redux users won't need to install Redux just for compose. |
NO. Again, I am not going to break the Redux public API, even in a major, just for the sake of some notion of "letting people bring their own If folks are installing Redux just to get |
Why removing an API just because a tool (TypeScript) is not able to do what's needed? |
The argument to extract the compose function outside of the library has nothing to do with TypeScript, which means it's out of scope from this thread. |
Here's my stance atm:
Otherwise, we'll just stick with what we've got and I'll close this. |
I'm just going to close this regardless. There are some potential avenues to explore, but it doesn't sound like this PR is one of them. |
It would have been an improved developer experience to have more correct type inference. |
Why was this closed? @markerikson just said this:
yet this PR fixes bugs in the existing overloads & doesn't pull in any external dependencies. From a previous comment in this thread:
FWIW, regardless of whether or not it happens (sounds like it won't), I agree with the point that a few people made: Redux shouldn't have a And, while TypeScript is far from the most powerful or most well designed type system out there, I want to push back on this
a little bit. These kinds of problems pop up often when JS code is written in a too loose, too dynamic way. The definition for Rewriting the library in TypeScript and preparing a major version update is the perfect time to fix the problems, either by removing the function from the library entirely or drastically simplifying it to make it more sound and easy to statically type. There is really no reason not to do one of these things other than just doubling down on historical mistakes. By the way, without rewriting the code in ((next: Dispatch<AnyAction>) => (action: any) => any)[] and the type of the function returned by (...args: any[]) => Dispatch<AnyAction> These are very loose types. |
sigh WHY IS EVERYONE SO INTENT ON REMOVING I honestly don't get the reasons why folks are saying that, and I don't know how much clearer I can be that WE ARE NOT GOING TO REMOVE This is why I get so frustrated with FP purists (as a general observation). Pragmatism, practicality, and working code matter, people. This isn't some theoretical issue we're talking about. If we remove Specifically to the "why is The biggest weakness with store enhancers is that Tim closed the PR because it sounded as if the approach here wasn't really adding anything useful. It's possible that's not the case, but the discussion in this thread has gotten out of hand. Per my prior comment, I'm still open to actual valid changes that improve the types, but if people keep yelling about removing On that note, I'm gonna lock this PR. I am legitimately requesting new PRs that actually improve the types, and let's pick up the discussion fresh on one of those, focused on just how to make the types better. |
I saw you discussing pulling in
ts-toolbelt
to rewrite thecompose
utility in #3563 and asking for alternative solutions on Twitter. I don't think you need to pull in a utility library to do so this, andts-toolbelt
's solution seemed more complex than necessary, utilizing a bunch of custom type-level helper utilities. So, I decided to take a shot at rewritingcompose
from scratch, using no external dependencies & avoidingany
entirely. This is what I came up with.Important notes:
There are no behavior changes other than a change to the generic type params used in various overloads. When explicitly providing generic params for overloads accepting multi-arg functions, you must now pass in a tuple of types for the generic instead of only the type. This is accomplished via the combination of
A extends unknown[]
&(...args: A)
. This actually fixes a bug that was present before which (accidentally, I believe) restricted these multi-arg functions to take in arguments all of the same type, which isn't necessary. You can see the changes I'm talking about reflected in the updated test.I did not change the actual logic in the implementation body of the function. We could rewrite it without
reduce
in a potentially more performant way, but I wanted to keep the changes in this PR restricted to how the types work so that it would be easier to review. Note: There are some changes in the implementation, but they are just related to type signatures. Also, we could useFunction
where I'm usingFn
andunknown[]
where I'm usingParameters<typeof b>
if you don't like what I did with these. It would still work the same way.I think what's in the PR now is an improvement and could be merged as is (after fixing the line passing
...chain
tocompose
that's currently causing the build to fail), but, while I kept the actual functionality/behavior the same in this PR, I think it's worth considering making some further changes beyond what's been done so far in order to simplify what's required to properly type this function. I'll add more info on this in a followup comment.