-
-
Notifications
You must be signed in to change notification settings - Fork 1.2k
Include case reducers in createSlice result #209
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
Changes from all commits
67d96f7
a389eef
36e966f
dd7f19b
d9cf216
731c744
184e5ab
a34bf93
8f6add0
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -18,7 +18,9 @@ export type SliceActionCreator<P> = PayloadActionCreator<P> | |
|
||
export interface Slice< | ||
State = any, | ||
ActionCreators extends { [key: string]: any } = { [key: string]: any } | ||
CaseReducers extends SliceCaseReducerDefinitions<State, PayloadActions> = { | ||
[key: string]: any | ||
} | ||
> { | ||
/** | ||
* The slice name. | ||
|
@@ -34,15 +36,20 @@ export interface Slice< | |
* Action creators for the types of actions that are handled by the slice | ||
* reducer. | ||
*/ | ||
actions: ActionCreators | ||
actions: CaseReducerActions<CaseReducers> | ||
|
||
caseReducers: SliceDefinedCaseReducers<CaseReducers, State> | ||
} | ||
|
||
/** | ||
* Options for `createSlice()`. | ||
*/ | ||
export interface CreateSliceOptions< | ||
State = any, | ||
CR extends SliceCaseReducers<State, any> = SliceCaseReducers<State, any> | ||
CR extends SliceCaseReducerDefinitions< | ||
State, | ||
any | ||
> = SliceCaseReducerDefinitions<State, any> | ||
> { | ||
/** | ||
* The slice's name. Used to namespace the generated action types. | ||
|
@@ -74,23 +81,23 @@ type PayloadActions<Types extends keyof any = string> = Record< | |
PayloadAction | ||
> | ||
|
||
type EnhancedCaseReducer<State, Action extends PayloadAction> = { | ||
type CaseReducerWithPrepare<State, Action extends PayloadAction> = { | ||
reducer: CaseReducer<State, Action> | ||
prepare: PrepareAction<Action['payload']> | ||
} | ||
|
||
type SliceCaseReducers<State, PA extends PayloadActions> = { | ||
type SliceCaseReducerDefinitions<State, PA extends PayloadActions> = { | ||
[ActionType in keyof PA]: | ||
| CaseReducer<State, PA[ActionType]> | ||
| EnhancedCaseReducer<State, PA[ActionType]> | ||
| CaseReducerWithPrepare<State, PA[ActionType]> | ||
} | ||
|
||
type IfIsReducerFunctionWithoutAction<R, True, False = never> = R extends ( | ||
state: any | ||
) => any | ||
? True | ||
: False | ||
type IfIsEnhancedReducer<R, True, False = never> = R extends { | ||
type IfIsCaseReducerWithPrepare<R, True, False = never> = R extends { | ||
prepare: Function | ||
} | ||
? True | ||
|
@@ -106,8 +113,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 commentThe 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 commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe 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: |
||
state: S, | ||
action: PayloadAction<infer P> | ||
) => S | ||
? PayloadAction<P> | ||
: R extends { | ||
reducer(state: any, action: PayloadAction<infer P>): any | ||
} | ||
? PayloadAction<P> | ||
: unknown | ||
|
||
type CaseReducerActions< | ||
CaseReducers extends SliceCaseReducerDefinitions<any, any> | ||
markerikson marked this conversation as resolved.
Show resolved
Hide resolved
|
||
> = { | ||
[Type in keyof CaseReducers]: IfIsCaseReducerWithPrepare< | ||
CaseReducers[Type], | ||
ActionCreatorWithPreparedPayload< | ||
PrepareActionForReducer<CaseReducers[Type]> | ||
|
@@ -122,6 +142,16 @@ type CaseReducerActions<CaseReducers extends SliceCaseReducers<any, any>> = { | |
> | ||
} | ||
|
||
type SliceDefinedCaseReducers< | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. as a consequence, no need for the State parameter here as well |
||
CaseReducers extends SliceCaseReducerDefinitions<any, any>, | ||
State = any | ||
> = { | ||
[Type in keyof CaseReducers]: CaseReducer< | ||
State, | ||
ActionForReducer<CaseReducers[Type], State> | ||
> | ||
} | ||
|
||
type NoInfer<T> = [T][T extends any ? 0 : never] | ||
|
||
type SliceCaseReducersCheck<S, ACR> = { | ||
|
@@ -134,9 +164,9 @@ type SliceCaseReducersCheck<S, ACR> = { | |
: {} | ||
} | ||
|
||
type RestrictEnhancedReducersToMatchReducerAndPrepare< | ||
type RestrictCaseReducerDefinitionsToMatchReducerAndPrepare< | ||
S, | ||
CR extends SliceCaseReducers<S, any> | ||
CR extends SliceCaseReducerDefinitions<S, any> | ||
markerikson marked this conversation as resolved.
Show resolved
Hide resolved
|
||
> = { reducers: SliceCaseReducersCheck<S, NoInfer<CR>> } | ||
|
||
function getType(slice: string, actionKey: string): string { | ||
|
@@ -153,54 +183,59 @@ function getType(slice: string, actionKey: string): string { | |
*/ | ||
export function createSlice< | ||
State, | ||
CaseReducers extends SliceCaseReducers<State, any> | ||
CaseReducers extends SliceCaseReducerDefinitions<State, any> | ||
markerikson marked this conversation as resolved.
Show resolved
Hide resolved
|
||
>( | ||
options: CreateSliceOptions<State, CaseReducers> & | ||
RestrictEnhancedReducersToMatchReducerAndPrepare<State, CaseReducers> | ||
): Slice<State, CaseReducerActions<CaseReducers>> | ||
RestrictCaseReducerDefinitionsToMatchReducerAndPrepare<State, CaseReducers> | ||
): Slice<State, CaseReducers> | ||
|
||
// internal definition is a little less restrictive | ||
export function createSlice< | ||
State, | ||
CaseReducers extends SliceCaseReducers<State, any> | ||
CaseReducers extends SliceCaseReducerDefinitions<State, any> | ||
markerikson marked this conversation as resolved.
Show resolved
Hide resolved
|
||
>( | ||
options: CreateSliceOptions<State, CaseReducers> | ||
): Slice<State, CaseReducerActions<CaseReducers>> { | ||
): Slice<State, CaseReducers> { | ||
const { name, initialState } = options | ||
if (!name) { | ||
throw new Error('`name` is a required option for createSlice') | ||
} | ||
const reducers = options.reducers || {} | ||
const extraReducers = options.extraReducers || {} | ||
const actionKeys = Object.keys(reducers) | ||
|
||
const reducerMap = actionKeys.reduce((map, actionKey) => { | ||
let maybeEnhancedReducer = reducers[actionKey] | ||
map[getType(name, actionKey)] = | ||
typeof maybeEnhancedReducer === 'function' | ||
? maybeEnhancedReducer | ||
: maybeEnhancedReducer.reducer | ||
return map | ||
}, extraReducers) | ||
|
||
const reducer = createReducer(initialState, reducerMap) | ||
|
||
const actionMap = actionKeys.reduce( | ||
(map, action) => { | ||
let maybeEnhancedReducer = reducers[action] | ||
const type = getType(name, action) | ||
map[action] = | ||
typeof maybeEnhancedReducer === 'function' | ||
? createAction(type) | ||
: createAction(type, maybeEnhancedReducer.prepare) | ||
return map | ||
}, | ||
{} as any | ||
) | ||
const reducerNames = Object.keys(reducers) | ||
|
||
const sliceCaseReducersByName: Record<string, CaseReducer> = {} | ||
const sliceCaseReducersByType: Record<string, CaseReducer> = {} | ||
const actionCreators: Record<string, PayloadActionCreator> = {} | ||
|
||
reducerNames.forEach(reducerName => { | ||
const maybeReducerWithPrepare = reducers[reducerName] | ||
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 commentThe reason will be displayed to describe this comment to others. Learn more. See previous comment |
||
|
||
if (typeof maybeReducerWithPrepare === 'function') { | ||
caseReducer = maybeReducerWithPrepare | ||
} else { | ||
caseReducer = maybeReducerWithPrepare.reducer | ||
prepareCallback = maybeReducerWithPrepare.prepare | ||
} | ||
|
||
sliceCaseReducersByName[reducerName] = caseReducer | ||
sliceCaseReducersByType[type] = caseReducer | ||
actionCreators[reducerName] = prepareCallback | ||
? createAction(type, prepareCallback) | ||
: createAction(type) | ||
}) | ||
|
||
const finalCaseReducers = { ...extraReducers, ...sliceCaseReducersByType } | ||
const reducer = createReducer(initialState, finalCaseReducers) | ||
|
||
return { | ||
name, | ||
reducer, | ||
actions: actionMap | ||
actions: actionCreators as any, | ||
caseReducers: sliceCaseReducersByName as any | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -147,6 +147,57 @@ function expectType<T>(t: T) { | |
expectType<string>(counter.actions.concatMetaStrLen('test').meta) | ||
} | ||
|
||
/* | ||
* Test: returned case reducer has the correct type | ||
*/ | ||
{ | ||
const counter = createSlice({ | ||
name: 'counter', | ||
initialState: 0, | ||
reducers: { | ||
increment(state, action: PayloadAction<number>) { | ||
return state + action.payload | ||
}, | ||
decrement: { | ||
reducer(state, action: PayloadAction<number>) { | ||
return state - action.payload | ||
}, | ||
prepare(amount: number) { | ||
return { payload: amount } | ||
} | ||
} | ||
} | ||
}) | ||
|
||
// Should match positively | ||
expectType<(state: number, action: PayloadAction<number>) => number | void>( | ||
counter.caseReducers.increment | ||
) | ||
|
||
// Should match positively for reducers with prepare callback | ||
expectType<(state: number, action: PayloadAction<number>) => number | void>( | ||
counter.caseReducers.decrement | ||
) | ||
|
||
// Should not mismatch the payload if it's a simple reducer | ||
// typings:expect-error | ||
expectType<(state: number, action: PayloadAction<string>) => number | void>( | ||
counter.caseReducers.increment | ||
) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 commentThe 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 commentThe reason will be displayed to describe this comment to others. Learn more. I'd add one non-error-check for There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
) |
||
|
||
// Should not mismatch the payload if it's a reducer with a prepare callback | ||
// typings:expect-error | ||
expectType<(state: number, action: PayloadAction<string>) => number | void>( | ||
counter.caseReducers.decrement | ||
) | ||
|
||
// Should not include entries that don't exist | ||
// typings:expect-error | ||
expectType<(state: number, action: PayloadAction<string>) => number | void>( | ||
counter.caseReducers.someThingNonExistant | ||
) | ||
} | ||
|
||
/* | ||
* Test: prepared payload does not match action payload - should cause an error. | ||
*/ | ||
|
@@ -180,6 +231,7 @@ function expectType<T>(t: T) { | |
} | ||
|
||
const mySlice = createSlice({ | ||
name: 'name', | ||
initialState, | ||
reducers: { | ||
setName: (state, action) => { | ||
|
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 fromany
tounknown
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.