-
-
Notifications
You must be signed in to change notification settings - Fork 1.2k
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
Include case reducers in createSlice result #209
Conversation
- Added `caseReducers` field to the createSlice return object - Added test and type test for returned case reducer - Removed "Enhanced" terminology from types, and replaced with "CaseReducerWithPrepare" and "CaseReducerDefinition" - Restructured logic to only loop over reducer names once, and check case reducer definition type once per entry
@phryneas , @denisw , @Dudeonyx , @Jessidhia , @nickmccurdy : can I get some eyes on this to make sure I didn't do anything really stupid? The unit tests and type tests still pass, and it looks like everything is being inferred correctly in the output. |
Deploy preview for redux-starter-kit-docs ready! Built with commit 8f6add0 https://deploy-preview-209--redux-starter-kit-docs.netlify.com |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It needs the round-trip to something with the initial reducer types, or it just accepts anything.
If you want to, I can give it a try in the evening - except if you want to experiment around with it? :)
src/createSlice.ts
Outdated
@@ -35,14 +35,19 @@ export interface Slice< | |||
* reducer. | |||
*/ | |||
actions: ActionCreators | |||
|
|||
caseReducers: SliceCaseReducers<State, any> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is too generic, it needs some relation to the original reducers or it will broaden to accept any type of reducer with just any key. I'll add two example tests below.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Trying to poke at this now. Got any suggestions for approaches?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
A possible solution (that is potentially breaking) is to change the type of the second generic argument on most things in this file; a lot of things here are overly broad, not just the changes done in this PR.
Make them all use the PA extends PayloadActions
instead of types that are derived from it; put the derivation in the parameter list / helper types / interface declarations themselves. The fundamental units here are the State
object and the PayloadActions
that are handled, as they're present in almost all types and/or are used to generate type arguments for other types.
Speaking of which, PayloadActions
itself is erasing types; it does not preserve the "individuality" of each PayloadAction
it contains; fixing that is even more involved than this, though, and I think the current problems we're pointing out can be fixed before/without addressing that as it's only used in extends
clauses instead of concrete instances (higher kinded types yay 😓).
For example, this Slice
interface should receive State
and PA extends PayloadActions
, the type of actions
should be (what currently is) CaseReducerActions<SliceCaseReducers<State, PA>>
(they're all already createAction
ed, you can no longer have CaseReducerWithPrepare
objects in the result), and type of of caseReducers
should be SliceCaseReducers<State, PA>
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for the suggestions.
I will freely admit that part of me wants to throw my hands up and say "someone else fix this!", and it may come to that. But, I'd also like to start becoming at least not completely incompetent in this area.
For the moment, is there a simpler approach that would help improve the current PR without trying to redo the rest of the types right now? Should we just get this PR in and tackle everything else in a follow-up?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
For a simple approach:
- the createSlice method signature passes
CaseReducers
on to Slice instead of currentlyCaseReducerActions<CaseReducers>
- Slice changes it's second generic argument from
ActionCreators extends { [key: string]: any } = { [key: string]: any }
toCaseReducers extends SliceCaseReducerDefinitions<State, unknown>
(I believe the default value would even satisfy that, as broad as it is) actions: ActionCreators
becomes typeCaseReducerActions<CaseReducers>
that way, you preserve given behaviour, but within Slice, you now have access to the CaseReducers
instead of the derived CaseReducerActions
.
Then you have to create a new type that maps the values of CaseReducers to always the reducer, and use that for the caseReducers.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Another option with maybe even less changes would be to change your definition of
// from
type SliceCaseReducers<State, PA extends PayloadActions> = {
[ActionType in keyof PA]: CaseReducer<State, PA[ActionType]>
}
// to something like
type SliceCaseReducers<State, ActionCreators extends { [key: string]: unknown }> = {
[ActionType in keyof ActionCreators]: CaseReducer<State, ReturnValue<ActionCreators[ActionType]>>
}
And actually pass ActionCreators as a second parameter to this interface, instead of any.
But honestly, I'm not sure if that will work out in the long run (also, I've written this without actually testing it, so it might not work)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Trying your CaseReducers
suggestion now, and I'm getting two errors:
Type 'unknown' does not satisfy the constraint 'Record<string, WithPayload<any, Action<string>>>'
Required type parameters may not follow optional type parameters.
I can resolve the first one by replacing unknown
with PayloadAction
(the record type). Trying to figure out what an appropriate default type might be here.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Resolved the second error by simply swapping the order of the generics in Slice
:)
So. Coming up with a type for the reducers. I feel like the CaseReducerActions
type is roughly what I want to mimic. I "just" need to figure out how to reference the reducer functions instead.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
And I think I have something!
|
||
expectType<(state: number, action: PayloadAction<number>) => number | void>( | ||
counter.caseReducers.increment | ||
) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
these two additional tests should be failing, but currently don't
// typings:expect-error
expectType<(state: number, action: PayloadAction<string>) => number | void>(
counter.caseReducers.increment
)
// typings:expect-error
expectType<(state: number, action: PayloadAction<string>) => number | void>(
counter.caseReducers.someThingNonExistant
)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks! These were a huge help!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'd add one non-error-check for decrement
here, as currently you're just testing for expect-errors on that one.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
something like this one.
// Should match positively for reducers with prepare callback
expectType<(state: number, action: PayloadAction<number>) => number | void>(
counter.caseReducers.decrement
)
CR extends SliceCaseReducers<State, any> = SliceCaseReducers<State, any> | ||
CR extends SliceCaseReducerDefinitions< | ||
State, | ||
any |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Use unknown instead of any (requires TypeScript 3)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This one was there already, but yeah, we can try that.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nope. Adding unknown
in the places you're suggesting just broke all the existing type tests.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What's the error? It's possible the type tests are running on an old version of TS, or that they need type guards.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
redux-starter-kit/src/createSlice.ts (49, 5) Type 'unknown' does not satisfy the constraint 'Record<string, WithPayload<any, Action<string>>>'.
And no, we're on TS 3.4.3 right now.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I’m not sure why that doesn’t match, but if the return type uses any anyway, maybe this can be left alone for now
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You can pretty easily change all those parts in the extends
statement from any
to unknown
without consequence.
But doing it for the default value will in some places be a breaking change for people who didn't specify a type manually and where the type could not be inferred.
Arguably, this is how they shouldn't be using TS in the first place if they want it for type-safety, but it definitely is used that way (see #165), so maybe that should be done with caution in a different PR.
src/createSlice.ts
Outdated
|
||
const sliceCaseReducersByName: SliceCaseReducers<State, any> = {} | ||
const sliceCaseReducersByType: SliceCaseReducers<State, any> = {} | ||
const actionCreators: any = {} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
By forcing these types to be any, the inference on the returned reducers will be broken. It's better to use inference features (such as mapped types) and rely on a generic defaulting to any/unknown types instead. Let me know if you need help figuring this out.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The actionCreators : any
here was actually how the existing code worked as far as I can see, and the types of the exported action creators are still correct based on VS Code's inspection and the type tests.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
As the method has a defined return type, as long as the value you are returning somehow satisfies that type, you can do pretty much inside - the outside interface is defined.
Even casting to any would be valid here, and in some places I think it is to make stuff less difficult.
As an alternative, for example you could type the actionCreators
to CaseReducerActions<CaseReducers>
. As a result, the assignment of {}
would fail, and you would have to assign it {} as any
instead.
But you've got to die one death here, and I think that would be acceptable, as it would at least guard all assignments. (Even though it could never make sure all the values were actually assigned, just that those being assigned are correct. But still, more typesafe than any
alone)
But, as I said, even typing this as any
as it is is probably okay - as long as the method is tested thoroughly, and that it is.
const type = getType(name, reducerName) | ||
|
||
let caseReducer: CaseReducer<State, any> | ||
let prepareCallback: PrepareAction<any> | undefined |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
See previous comment
Not sure I quite understand what you're saying here. Can you clarify? |
Awright, I think I actually have a working solution here! I added the two type tests suggested by @phryneas , and attempted to implement the suggested solution ( #209 (comment) ). I fixed the "can't have a required type after a default type" error by swapping the order of the two generics in From there, I tried to set up some kind of mapping, loosely based on the type ExtractPayloadActions<T> = T extends SliceCaseReducerDefinitions<
any,
infer PA
>
? PA
: never
type OnlyCaseReducers<
CaseReducers extends SliceCaseReducerDefinitions<any, any>,
State = any
> = {
[Type in keyof CaseReducers]: CaseReducer<
State,
ExtractPayloadActions<CaseReducers>[Type]
>
} I eventually figured out that it was basically falling back to a default That clued me in that I need to extract the specific action type for this situation. Inspired by the type ActionForReducer<R, ActionType = Action<string>> = R extends (
state: any,
action: PayloadAction<infer P>
) => any
? PayloadAction<P>
: ActionType
type OnlyCaseReducers<
CaseReducers extends SliceCaseReducerDefinitions<any, any>,
State = any
> = {
[Type in keyof CaseReducers]: CaseReducer<
State,
ActionForReducer<CaseReducers[Type]>
>
} That got both the "mismatched payload" and "nonexisting field" type tests throwing errors as expected. I'm not sold on the name |
You're on a good way here, but not quite there yet (and those types are complicated, so every little bit of frustration you might be feeling is perfectly understandable!). The important thing here is to identify why your ExtractPayloadActions type or ActionForReducer might fail. First your In your Take a look at potential shapes that could be passed to the But there is another shape you can test for that we definitely know of:
so you can do a second nested ternary there, testing for that type and inferring P from there. As for the "does not match any of the above" branch, I would not go for your ActionType or any Action, but just for I would suggest you add another test for the case with an enhanced reducer (including expect-error tests like I wrote above) - and then you're really super close :) |
@phryneas : thanks for coaching me through this! As I said, part of me just wants someone to fix all this and make it work so we can push RSK up to 1.0 ASAP, but at the same time I really need to start becoming not-totally-incompetent with this complex type stuff. I'll try to tackle your suggested directions myself and see what happens. |
@phryneas : had to read through your instructions a couple times, but turns out you were really walking me through this one, weren't you :) (I recognize the "tell the clueless n00b the exact steps they need to do, politely" phrasing. Goodness knows I've done that enough times myself.) Added the additional type test, implemented the ternary, and saw that test start failing as expected. From there, I cleaned up a few of the types in How's this look now? |
@@ -106,8 +111,21 @@ type PrepareActionForReducer<R> = R extends { prepare: infer Prepare } | |||
? Prepare | |||
: never | |||
|
|||
type CaseReducerActions<CaseReducers extends SliceCaseReducers<any, any>> = { | |||
[Type in keyof CaseReducers]: IfIsEnhancedReducer< | |||
type ActionForReducer<R, S> = R extends ( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
As you don't really care for the State type here, I believe you can just leave it out.
type ActionForReducer<R> =
R extends ( state: any, action: PayloadAction<infer P> ) => any
? PayloadAction<P>
: R extends { reducer(state: any, action: PayloadAction<infer P>): any }
? PayloadAction<P>
: unknown
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, that's basically what I originally had. @nickmccurdy suggested I might as well use State
instead of any
for consistency, and it doesn't seem to actually hurt anything.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It doesn't hurt - it's just one more thing to carry around :)
Explanation why I think it's not necessary here:
What you are doing here is shape-matching. You're either looking for an object with a reducer
method or for a method itself, both methods with at least two arguments. From that moment on you just care about the type of the second argument.
Sure, you can choose to only match if the first argument is State
, but that means a second parameter to the generic, also caring about that second parameter when calling. So, essentially: more code, without any direct benefit.
So I'd just let it out. But if you choose to let it in there, don't forget to do the same check in line 120, too.
@@ -122,6 +140,16 @@ type CaseReducerActions<CaseReducers extends SliceCaseReducers<any, any>> = { | |||
> | |||
} | |||
|
|||
type SliceDefinedCaseReducers< |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
as a consequence, no need for the State parameter here as well
A little bit - but in the end, you did it, with all the failures that come along on the way. Just a little secret: for the reducer with payload stuff, I wrote that whole thing at least four times from scratch, completely resetting because something was not behaving as I wanted and I was going routine-blinded with my existing code. Wanted to spare you of that frustration, because it is not a learning one ;) The code looks good now, I've added two more small comments, but I believe you did it :) It's early in the morning here and I haven't had my coffee yet, so I'll do a second round of review in the evening, just in case I missed something. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, gave this another round, I believe it looks good now.
All open comments from me are just nitpicks, so do them if you feel like it, but it's fine either way 👍
src/createSlice.ts
Outdated
State = any, | ||
ActionCreators extends { [key: string]: any } = { [key: string]: any } | ||
CaseReducers extends SliceCaseReducerDefinitions<State, PayloadActions>, | ||
State = any |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You could bring this back into the original order by giving the CaseReducers the same default value as ActionCreators had before (that should be working). That way, things might still break for ppl who used this type directly, but maybe not as significantly.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sure, that works.
|
||
expectType<(state: number, action: PayloadAction<number>) => number | void>( | ||
counter.caseReducers.increment | ||
) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
something like this one.
// Should match positively for reducers with prepare callback
expectType<(state: number, action: PayloadAction<number>) => number | void>(
counter.caseReducers.decrement
)
Awright, let's do this! |
* Create slice changes (#197) * Include case reducers in createSlice result (#209) * Fix missing name field in type test * Include provided case reducers in createSlice output - Added `caseReducers` field to the createSlice return object - Added test and type test for returned case reducer - Removed "Enhanced" terminology from types, and replaced with "CaseReducerWithPrepare" and "CaseReducerDefinition" - Restructured logic to only loop over reducer names once, and check case reducer definition type once per entry * Add type tests for correct case reducer export inference * Rewrite caseReducers types for correct inference of state + action * Add type test for reducers with prepare callback * Fix type inference for actions from reducers w/ prepare callbacks * Clean up type names and usages * Use a generic State type in ActionForReducer * Re-switch Slice generics order * Use `void` instead of `any` for undefined payloads (#174) * Change default createAction payload type to void to make it required * Fix createAction type tests per review * Update tutorials for v0.8 (#213) * Update basic tutorial with createSlice changes * Update intermediate tutorial with createSlice changes * Update advanced tutorial with createSlice changes * Change existing commit links to point to reduxjs org repos
Per discussion at #82 (comment)