Skip to content

Commit 209bf06

Browse files
authored
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
1 parent 7e57112 commit 209bf06

File tree

3 files changed

+136
-43
lines changed

3 files changed

+136
-43
lines changed

src/createSlice.test.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ describe('createSlice', () => {
3535
})
3636

3737
describe('when passing slice', () => {
38-
const { actions, reducer } = createSlice({
38+
const { actions, reducer, caseReducers } = createSlice({
3939
reducers: {
4040
increment: state => state + 1
4141
},
@@ -57,6 +57,12 @@ describe('createSlice', () => {
5757
it('should return the correct value from reducer', () => {
5858
expect(reducer(undefined, actions.increment())).toEqual(1)
5959
})
60+
61+
it('should include the generated case reducers', () => {
62+
expect(caseReducers).toBeTruthy()
63+
expect(caseReducers.increment).toBeTruthy()
64+
expect(typeof caseReducers.increment).toBe('function')
65+
})
6066
})
6167

6268
describe('when mutating state object', () => {

src/createSlice.ts

Lines changed: 77 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,9 @@ export type SliceActionCreator<P> = PayloadActionCreator<P>
1818

1919
export interface Slice<
2020
State = any,
21-
ActionCreators extends { [key: string]: any } = { [key: string]: any }
21+
CaseReducers extends SliceCaseReducerDefinitions<State, PayloadActions> = {
22+
[key: string]: any
23+
}
2224
> {
2325
/**
2426
* The slice name.
@@ -34,15 +36,20 @@ export interface Slice<
3436
* Action creators for the types of actions that are handled by the slice
3537
* reducer.
3638
*/
37-
actions: ActionCreators
39+
actions: CaseReducerActions<CaseReducers>
40+
41+
caseReducers: SliceDefinedCaseReducers<CaseReducers, State>
3842
}
3943

4044
/**
4145
* Options for `createSlice()`.
4246
*/
4347
export interface CreateSliceOptions<
4448
State = any,
45-
CR extends SliceCaseReducers<State, any> = SliceCaseReducers<State, any>
49+
CR extends SliceCaseReducerDefinitions<
50+
State,
51+
any
52+
> = SliceCaseReducerDefinitions<State, any>
4653
> {
4754
/**
4855
* The slice's name. Used to namespace the generated action types.
@@ -74,23 +81,23 @@ type PayloadActions<Types extends keyof any = string> = Record<
7481
PayloadAction
7582
>
7683

77-
type EnhancedCaseReducer<State, Action extends PayloadAction> = {
84+
type CaseReducerWithPrepare<State, Action extends PayloadAction> = {
7885
reducer: CaseReducer<State, Action>
7986
prepare: PrepareAction<Action['payload']>
8087
}
8188

82-
type SliceCaseReducers<State, PA extends PayloadActions> = {
89+
type SliceCaseReducerDefinitions<State, PA extends PayloadActions> = {
8390
[ActionType in keyof PA]:
8491
| CaseReducer<State, PA[ActionType]>
85-
| EnhancedCaseReducer<State, PA[ActionType]>
92+
| CaseReducerWithPrepare<State, PA[ActionType]>
8693
}
8794

8895
type IfIsReducerFunctionWithoutAction<R, True, False = never> = R extends (
8996
state: any
9097
) => any
9198
? True
9299
: False
93-
type IfIsEnhancedReducer<R, True, False = never> = R extends {
100+
type IfIsCaseReducerWithPrepare<R, True, False = never> = R extends {
94101
prepare: Function
95102
}
96103
? True
@@ -106,8 +113,21 @@ type PrepareActionForReducer<R> = R extends { prepare: infer Prepare }
106113
? Prepare
107114
: never
108115

109-
type CaseReducerActions<CaseReducers extends SliceCaseReducers<any, any>> = {
110-
[Type in keyof CaseReducers]: IfIsEnhancedReducer<
116+
type ActionForReducer<R, S> = R extends (
117+
state: S,
118+
action: PayloadAction<infer P>
119+
) => S
120+
? PayloadAction<P>
121+
: R extends {
122+
reducer(state: any, action: PayloadAction<infer P>): any
123+
}
124+
? PayloadAction<P>
125+
: unknown
126+
127+
type CaseReducerActions<
128+
CaseReducers extends SliceCaseReducerDefinitions<any, any>
129+
> = {
130+
[Type in keyof CaseReducers]: IfIsCaseReducerWithPrepare<
111131
CaseReducers[Type],
112132
ActionCreatorWithPreparedPayload<
113133
PrepareActionForReducer<CaseReducers[Type]>
@@ -122,6 +142,16 @@ type CaseReducerActions<CaseReducers extends SliceCaseReducers<any, any>> = {
122142
>
123143
}
124144

145+
type SliceDefinedCaseReducers<
146+
CaseReducers extends SliceCaseReducerDefinitions<any, any>,
147+
State = any
148+
> = {
149+
[Type in keyof CaseReducers]: CaseReducer<
150+
State,
151+
ActionForReducer<CaseReducers[Type], State>
152+
>
153+
}
154+
125155
type NoInfer<T> = [T][T extends any ? 0 : never]
126156

127157
type SliceCaseReducersCheck<S, ACR> = {
@@ -134,9 +164,9 @@ type SliceCaseReducersCheck<S, ACR> = {
134164
: {}
135165
}
136166

137-
type RestrictEnhancedReducersToMatchReducerAndPrepare<
167+
type RestrictCaseReducerDefinitionsToMatchReducerAndPrepare<
138168
S,
139-
CR extends SliceCaseReducers<S, any>
169+
CR extends SliceCaseReducerDefinitions<S, any>
140170
> = { reducers: SliceCaseReducersCheck<S, NoInfer<CR>> }
141171

142172
function getType(slice: string, actionKey: string): string {
@@ -153,54 +183,59 @@ function getType(slice: string, actionKey: string): string {
153183
*/
154184
export function createSlice<
155185
State,
156-
CaseReducers extends SliceCaseReducers<State, any>
186+
CaseReducers extends SliceCaseReducerDefinitions<State, any>
157187
>(
158188
options: CreateSliceOptions<State, CaseReducers> &
159-
RestrictEnhancedReducersToMatchReducerAndPrepare<State, CaseReducers>
160-
): Slice<State, CaseReducerActions<CaseReducers>>
189+
RestrictCaseReducerDefinitionsToMatchReducerAndPrepare<State, CaseReducers>
190+
): Slice<State, CaseReducers>
161191

162192
// internal definition is a little less restrictive
163193
export function createSlice<
164194
State,
165-
CaseReducers extends SliceCaseReducers<State, any>
195+
CaseReducers extends SliceCaseReducerDefinitions<State, any>
166196
>(
167197
options: CreateSliceOptions<State, CaseReducers>
168-
): Slice<State, CaseReducerActions<CaseReducers>> {
198+
): Slice<State, CaseReducers> {
169199
const { name, initialState } = options
170200
if (!name) {
171201
throw new Error('`name` is a required option for createSlice')
172202
}
173203
const reducers = options.reducers || {}
174204
const extraReducers = options.extraReducers || {}
175-
const actionKeys = Object.keys(reducers)
176-
177-
const reducerMap = actionKeys.reduce((map, actionKey) => {
178-
let maybeEnhancedReducer = reducers[actionKey]
179-
map[getType(name, actionKey)] =
180-
typeof maybeEnhancedReducer === 'function'
181-
? maybeEnhancedReducer
182-
: maybeEnhancedReducer.reducer
183-
return map
184-
}, extraReducers)
185-
186-
const reducer = createReducer(initialState, reducerMap)
187-
188-
const actionMap = actionKeys.reduce(
189-
(map, action) => {
190-
let maybeEnhancedReducer = reducers[action]
191-
const type = getType(name, action)
192-
map[action] =
193-
typeof maybeEnhancedReducer === 'function'
194-
? createAction(type)
195-
: createAction(type, maybeEnhancedReducer.prepare)
196-
return map
197-
},
198-
{} as any
199-
)
205+
const reducerNames = Object.keys(reducers)
206+
207+
const sliceCaseReducersByName: Record<string, CaseReducer> = {}
208+
const sliceCaseReducersByType: Record<string, CaseReducer> = {}
209+
const actionCreators: Record<string, PayloadActionCreator> = {}
210+
211+
reducerNames.forEach(reducerName => {
212+
const maybeReducerWithPrepare = reducers[reducerName]
213+
const type = getType(name, reducerName)
214+
215+
let caseReducer: CaseReducer<State, any>
216+
let prepareCallback: PrepareAction<any> | undefined
217+
218+
if (typeof maybeReducerWithPrepare === 'function') {
219+
caseReducer = maybeReducerWithPrepare
220+
} else {
221+
caseReducer = maybeReducerWithPrepare.reducer
222+
prepareCallback = maybeReducerWithPrepare.prepare
223+
}
224+
225+
sliceCaseReducersByName[reducerName] = caseReducer
226+
sliceCaseReducersByType[type] = caseReducer
227+
actionCreators[reducerName] = prepareCallback
228+
? createAction(type, prepareCallback)
229+
: createAction(type)
230+
})
231+
232+
const finalCaseReducers = { ...extraReducers, ...sliceCaseReducersByType }
233+
const reducer = createReducer(initialState, finalCaseReducers)
200234

201235
return {
202236
name,
203237
reducer,
204-
actions: actionMap
238+
actions: actionCreators as any,
239+
caseReducers: sliceCaseReducersByName as any
205240
}
206241
}

type-tests/files/createSlice.typetest.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,57 @@ function expectType<T>(t: T) {
147147
expectType<string>(counter.actions.concatMetaStrLen('test').meta)
148148
}
149149

150+
/*
151+
* Test: returned case reducer has the correct type
152+
*/
153+
{
154+
const counter = createSlice({
155+
name: 'counter',
156+
initialState: 0,
157+
reducers: {
158+
increment(state, action: PayloadAction<number>) {
159+
return state + action.payload
160+
},
161+
decrement: {
162+
reducer(state, action: PayloadAction<number>) {
163+
return state - action.payload
164+
},
165+
prepare(amount: number) {
166+
return { payload: amount }
167+
}
168+
}
169+
}
170+
})
171+
172+
// Should match positively
173+
expectType<(state: number, action: PayloadAction<number>) => number | void>(
174+
counter.caseReducers.increment
175+
)
176+
177+
// Should match positively for reducers with prepare callback
178+
expectType<(state: number, action: PayloadAction<number>) => number | void>(
179+
counter.caseReducers.decrement
180+
)
181+
182+
// Should not mismatch the payload if it's a simple reducer
183+
// typings:expect-error
184+
expectType<(state: number, action: PayloadAction<string>) => number | void>(
185+
counter.caseReducers.increment
186+
)
187+
188+
// Should not mismatch the payload if it's a reducer with a prepare callback
189+
// typings:expect-error
190+
expectType<(state: number, action: PayloadAction<string>) => number | void>(
191+
counter.caseReducers.decrement
192+
)
193+
194+
// Should not include entries that don't exist
195+
// typings:expect-error
196+
expectType<(state: number, action: PayloadAction<string>) => number | void>(
197+
counter.caseReducers.someThingNonExistant
198+
)
199+
}
200+
150201
/*
151202
* Test: prepared payload does not match action payload - should cause an error.
152203
*/
@@ -180,6 +231,7 @@ function expectType<T>(t: T) {
180231
}
181232

182233
const mySlice = createSlice({
234+
name: 'name',
183235
initialState,
184236
reducers: {
185237
setName: (state, action) => {

0 commit comments

Comments
 (0)