Skip to content

Commit c033b99

Browse files
phryneasmarkerikson
authored andcommitted
add prepareAction option to createAction (#149)
* add prepareAction option to createAction, prepare option to reducers/slices * move logic out of createReducer into createSlice * Fixed typings to raise an error if the type of the payload returned by prepare and the type accepted by reducer don't agree. * clean up some type tests
1 parent 36c7b7c commit c033b99

File tree

7 files changed

+347
-59
lines changed

7 files changed

+347
-59
lines changed

src/createAction.test.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,50 @@ describe('createAction', () => {
1515
expect(`${actionCreator}`).toEqual('A_TYPE')
1616
})
1717
})
18+
19+
describe('when passing a prepareAction method only returning a payload', () => {
20+
it('should use the payload returned from the prepareAction method', () => {
21+
const actionCreator = createAction('A_TYPE', (a: number) => ({
22+
payload: a * 2
23+
}))
24+
expect(actionCreator(5).payload).toBe(10)
25+
})
26+
it('should not have a meta attribute on the resulting Action', () => {
27+
const actionCreator = createAction('A_TYPE', (a: number) => ({
28+
payload: a * 2
29+
}))
30+
expect('meta' in actionCreator(5)).toBeFalsy()
31+
})
32+
})
33+
34+
describe('when passing a prepareAction method returning a payload and meta', () => {
35+
it('should use the payload returned from the prepareAction method', () => {
36+
const actionCreator = createAction('A_TYPE', (a: number) => ({
37+
payload: a * 2,
38+
meta: a / 2
39+
}))
40+
expect(actionCreator(5).payload).toBe(10)
41+
})
42+
it('should use the meta returned from the prepareAction method', () => {
43+
const actionCreator = createAction('A_TYPE', (a: number) => ({
44+
payload: a * 2,
45+
meta: a / 2
46+
}))
47+
expect(actionCreator(10).meta).toBe(5)
48+
})
49+
})
50+
51+
describe('when passing a prepareAction that accepts multiple arguments', () => {
52+
it('should pass all arguments of the resulting actionCreator to prepareAction', () => {
53+
const actionCreator = createAction(
54+
'A_TYPE',
55+
(a: string, b: string, c: string) => ({
56+
payload: a + b + c
57+
})
58+
)
59+
expect(actionCreator('1', '2', '3').payload).toBe('123')
60+
})
61+
})
1862
})
1963

2064
describe('getType', () => {

src/createAction.ts

Lines changed: 63 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -7,33 +7,50 @@ import { Action } from 'redux'
77
* @template P The type of the action's payload.
88
* @template T the type used for the action type.
99
*/
10-
export interface PayloadAction<P = any, T extends string = string>
11-
extends Action<T> {
10+
export type PayloadAction<
11+
P = any,
12+
T extends string = string,
13+
M = void
14+
> = Action<T> & {
1215
payload: P
13-
}
16+
} & ([M] extends [void] ? {} : { meta: M })
17+
18+
export type Diff<T, U> = T extends U ? never : T
1419

15-
export type Diff<T, U> = T extends U ? never : T;
20+
export type PrepareAction<P> =
21+
| ((...args: any[]) => { payload: P })
22+
| ((...args: any[]) => { payload: P; meta: any })
1623

1724
/**
1825
* An action creator that produces actions with a `payload` attribute.
1926
*/
20-
export type PayloadActionCreator<P = any, T extends string = string> = { type: T } & (
21-
/*
22-
* The `P` generic is wrapped with a single-element tuple to prevent the
23-
* conditional from being checked distributively, thus preserving unions
24-
* of contra-variant types.
25-
*/
26-
[undefined] extends [P] ? {
27-
(payload?: undefined): PayloadAction<undefined, T>
28-
<PT extends Diff<P, undefined>>(payload?: PT): PayloadAction<PT, T>
29-
}
30-
: [void] extends [P] ? {
31-
(): PayloadAction<undefined, T>
32-
}
33-
: {
34-
<PT extends P>(payload: PT): PayloadAction<PT, T>
35-
}
36-
);
27+
export type PayloadActionCreator<
28+
P = any,
29+
T extends string = string,
30+
PA extends PrepareAction<P> | void = void
31+
> = {
32+
type: T
33+
} & (PA extends (...args: any[]) => any
34+
? (ReturnType<PA> extends { meta: infer M }
35+
? (...args: Parameters<PA>) => PayloadAction<P, T, M>
36+
: (...args: Parameters<PA>) => PayloadAction<P, T>)
37+
: (/*
38+
* The `P` generic is wrapped with a single-element tuple to prevent the
39+
* conditional from being checked distributively, thus preserving unions
40+
* of contra-variant types.
41+
*/
42+
[undefined] extends [P]
43+
? {
44+
(payload?: undefined): PayloadAction<undefined, T>
45+
<PT extends Diff<P, undefined>>(payload?: PT): PayloadAction<PT, T>
46+
}
47+
: [void] extends [P]
48+
? {
49+
(): PayloadAction<undefined, T>
50+
}
51+
: {
52+
<PT extends P>(payload: PT): PayloadAction<PT, T>
53+
}))
3754

3855
/**
3956
* A utility function to create an action creator for the given action type
@@ -44,18 +61,38 @@ export type PayloadActionCreator<P = any, T extends string = string> = { type: T
4461
*
4562
* @param type The action type to use for created actions.
4663
*/
64+
4765
export function createAction<P = any, T extends string = string>(
4866
type: T
49-
): PayloadActionCreator<P, T> {
50-
function actionCreator(payload?: P): PayloadAction<undefined | P, T> {
51-
return { type, payload }
67+
): PayloadActionCreator<P, T>
68+
69+
export function createAction<
70+
PA extends PrepareAction<any>,
71+
T extends string = string
72+
>(
73+
type: T,
74+
prepareAction: PA
75+
): PayloadActionCreator<ReturnType<PA>['payload'], T, PA>
76+
77+
export function createAction(type: string, prepareAction?: Function) {
78+
function actionCreator(...args: any[]) {
79+
if (prepareAction) {
80+
let prepared = prepareAction(...args)
81+
if (!prepared) {
82+
throw new Error('prepareAction did not return an object')
83+
}
84+
return 'meta' in prepared
85+
? { type, payload: prepared.payload, meta: prepared.meta }
86+
: { type, payload: prepared.payload }
87+
}
88+
return { type, payload: args[0] }
5289
}
5390

54-
actionCreator.toString = (): T => `${type}` as T
91+
actionCreator.toString = () => `${type}`
5592

5693
actionCreator.type = type
5794

58-
return actionCreator as any
95+
return actionCreator
5996
}
6097

6198
/**

src/createSlice.test.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,4 +128,48 @@ describe('createSlice', () => {
128128
expect(result).toBe(15)
129129
})
130130
})
131+
132+
describe('behaviour with enhanced case reducers', () => {
133+
it('should pass all arguments to the prepare function', () => {
134+
const prepare = jest.fn((payload, somethingElse) => ({ payload }))
135+
136+
const testSlice = createSlice({
137+
slice: 'test',
138+
initialState: 0,
139+
reducers: {
140+
testReducer: {
141+
reducer: s => s,
142+
prepare
143+
}
144+
}
145+
})
146+
147+
expect(testSlice.actions.testReducer('a', 1)).toEqual({
148+
type: 'test/testReducer',
149+
payload: 'a'
150+
})
151+
expect(prepare).toHaveBeenCalledWith('a', 1)
152+
})
153+
154+
it('should call the reducer function', () => {
155+
const reducer = jest.fn()
156+
157+
const testSlice = createSlice({
158+
slice: 'test',
159+
initialState: 0,
160+
reducers: {
161+
testReducer: {
162+
reducer,
163+
prepare: payload => ({ payload })
164+
}
165+
}
166+
})
167+
168+
testSlice.reducer(0, testSlice.actions.testReducer('testPayload'))
169+
expect(reducer).toHaveBeenCalledWith(
170+
0,
171+
expect.objectContaining({ payload: 'testPayload' })
172+
)
173+
})
174+
})
131175
})

src/createSlice.ts

Lines changed: 56 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
import { Reducer } from 'redux'
2-
import { createAction, PayloadAction, PayloadActionCreator } from './createAction'
3-
import { createReducer, CaseReducers } from './createReducer'
2+
import {
3+
createAction,
4+
PayloadAction,
5+
PayloadActionCreator,
6+
PrepareAction
7+
} from './createAction'
8+
import { createReducer, CaseReducers, CaseReducer } from './createReducer'
49
import { createSliceSelector, createSelectorName } from './sliceSelector'
510

611
/**
@@ -12,8 +17,8 @@ export type SliceActionCreator<P> = PayloadActionCreator<P>
1217

1318
export interface Slice<
1419
S = any,
15-
AP extends { [key: string]: any } = { [key: string]: any }
16-
> {
20+
AC extends { [key: string]: any } = { [key: string]: any }
21+
> {
1722
/**
1823
* The slice name.
1924
*/
@@ -28,7 +33,7 @@ export interface Slice<
2833
* Action creators for the types of actions that are handled by the slice
2934
* reducer.
3035
*/
31-
actions: { [type in keyof AP]: PayloadActionCreator<AP[type]> }
36+
actions: AC
3237

3338
/**
3439
* Selectors for the slice reducer state. `createSlice()` inserts a single
@@ -44,8 +49,8 @@ export interface Slice<
4449
*/
4550
export interface CreateSliceOptions<
4651
S = any,
47-
CR extends CaseReducers<S, any> = CaseReducers<S, any>
48-
> {
52+
CR extends SliceCaseReducers<S, any> = SliceCaseReducers<S, any>
53+
> {
4954
/**
5055
* The slice's name. Used to namespace the generated action types and to
5156
* name the selector for retrieving the reducer's state.
@@ -72,12 +77,36 @@ export interface CreateSliceOptions<
7277
extraReducers?: CaseReducers<S, any>
7378
}
7479

75-
type CaseReducerActionPayloads<CR extends CaseReducers<any, any>> = {
80+
type PayloadActions<T extends keyof any = string> = Record<T, PayloadAction>
81+
82+
type EnhancedCaseReducer<S, A extends PayloadAction> = {
83+
reducer: CaseReducer<S, A>
84+
prepare: PrepareAction<A['payload']>
85+
}
86+
87+
type SliceCaseReducers<S, PA extends PayloadActions> = {
88+
[T in keyof PA]: CaseReducer<S, PA[T]> | EnhancedCaseReducer<S, PA[T]>
89+
}
90+
91+
type CaseReducerActions<CR extends SliceCaseReducers<any, any>> = {
7692
[T in keyof CR]: CR[T] extends (state: any) => any
77-
? void
78-
: (CR[T] extends (state: any, action: PayloadAction<infer P>) => any
79-
? P
80-
: void)
93+
? PayloadActionCreator<void>
94+
: (CR[T] extends (state: any, action: PayloadAction<infer P>) => any
95+
? PayloadActionCreator<P>
96+
: CR[T] extends { prepare: PrepareAction<infer P> }
97+
? PayloadActionCreator<P, string, CR[T]['prepare']>
98+
: PayloadActionCreator<void>)
99+
}
100+
101+
type NoInfer<T> = [T][T extends any ? 0 : never];
102+
type SliceCaseReducersCheck<S, ACR> = {
103+
[P in keyof ACR] : ACR[P] extends {
104+
reducer(s:S, action?: { payload: infer O }): any
105+
} ? {
106+
prepare(...a:never[]): { payload: O }
107+
} : {
108+
109+
}
81110
}
82111

83112
function getType(slice: string, actionKey: string): string {
@@ -92,25 +121,36 @@ function getType(slice: string, actionKey: string): string {
92121
*
93122
* The `reducer` argument is passed to `createReducer()`.
94123
*/
95-
export function createSlice<S, CR extends CaseReducers<S, any>>(
124+
export function createSlice<S, CR extends SliceCaseReducers<S, any>>(
125+
options: CreateSliceOptions<S, CR> & { reducers: SliceCaseReducersCheck<S, NoInfer<CR>> }
126+
): Slice<S, CaseReducerActions<CR>>
127+
export function createSlice<S, CR extends SliceCaseReducers<S, any>>(
96128
options: CreateSliceOptions<S, CR>
97-
): Slice<S, CaseReducerActionPayloads<CR>> {
129+
): Slice<S, CaseReducerActions<CR>> {
98130
const { slice = '', initialState } = options
99131
const reducers = options.reducers || {}
100132
const extraReducers = options.extraReducers || {}
101133
const actionKeys = Object.keys(reducers)
102134

103135
const reducerMap = actionKeys.reduce((map, actionKey) => {
104-
map[getType(slice, actionKey)] = reducers[actionKey]
136+
let maybeEnhancedReducer = reducers[actionKey]
137+
map[getType(slice, actionKey)] =
138+
typeof maybeEnhancedReducer === 'function'
139+
? maybeEnhancedReducer
140+
: maybeEnhancedReducer.reducer
105141
return map
106142
}, extraReducers)
107143

108144
const reducer = createReducer(initialState, reducerMap)
109145

110146
const actionMap = actionKeys.reduce(
111147
(map, action) => {
148+
let maybeEnhancedReducer = reducers[action]
112149
const type = getType(slice, action)
113-
map[action] = createAction(type)
150+
map[action] =
151+
typeof maybeEnhancedReducer === 'function'
152+
? createAction(type)
153+
: createAction(type, maybeEnhancedReducer.prepare)
114154
return map
115155
},
116156
{} as any

0 commit comments

Comments
 (0)