Skip to content

Commit 1fc2f36

Browse files
phryneasmarkerikson
authored andcommitted
alternative callback-builder-style notation for actionsMap (#262)
* alternative callback-builder-style notation for actionsMap * add alternative api for extraReducers, move shared type tests to mapBuilders.typetest.ts * add/update docblocks
1 parent 5eb1cc7 commit 1fc2f36

File tree

9 files changed

+315
-6
lines changed

9 files changed

+315
-6
lines changed

src/createReducer.test.ts

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { createReducer, CaseReducer } from './createReducer'
2-
import { PayloadAction } from './createAction'
2+
import { PayloadAction, createAction } from './createAction'
33
import { Reducer } from 'redux'
44

55
interface Todo {
@@ -74,6 +74,73 @@ describe('createReducer', () => {
7474

7575
behavesLikeReducer(todosReducer)
7676
})
77+
78+
describe('alternative builder callback for actionMap', () => {
79+
const increment = createAction<number, 'increment'>('increment')
80+
const decrement = createAction<number, 'decrement'>('decrement')
81+
82+
test('can be used with ActionCreators', () => {
83+
const reducer = createReducer(0, builder =>
84+
builder
85+
.addCase(increment, (state, action) => state + action.payload)
86+
.addCase(decrement, (state, action) => state - action.payload)
87+
)
88+
expect(reducer(0, increment(5))).toBe(5)
89+
expect(reducer(5, decrement(5))).toBe(0)
90+
})
91+
test('can be used with string types', () => {
92+
const reducer = createReducer(0, builder =>
93+
builder
94+
.addCase(
95+
'increment',
96+
(state, action: { type: 'increment'; payload: number }) =>
97+
state + action.payload
98+
)
99+
.addCase(
100+
'decrement',
101+
(state, action: { type: 'decrement'; payload: number }) =>
102+
state - action.payload
103+
)
104+
)
105+
expect(reducer(0, increment(5))).toBe(5)
106+
expect(reducer(5, decrement(5))).toBe(0)
107+
})
108+
test('can be used with ActionCreators and string types combined', () => {
109+
const reducer = createReducer(0, builder =>
110+
builder
111+
.addCase(increment, (state, action) => state + action.payload)
112+
.addCase(
113+
'decrement',
114+
(state, action: { type: 'decrement'; payload: number }) =>
115+
state - action.payload
116+
)
117+
)
118+
expect(reducer(0, increment(5))).toBe(5)
119+
expect(reducer(5, decrement(5))).toBe(0)
120+
})
121+
test('will throw if the same type is used twice', () => {
122+
expect(() =>
123+
createReducer(0, builder =>
124+
builder
125+
.addCase(increment, (state, action) => state + action.payload)
126+
.addCase(increment, (state, action) => state + action.payload)
127+
.addCase(decrement, (state, action) => state - action.payload)
128+
)
129+
).toThrowErrorMatchingInlineSnapshot(
130+
`"addCase cannot be called with two reducers for the same action type"`
131+
)
132+
expect(() =>
133+
createReducer(0, builder =>
134+
builder
135+
.addCase(increment, (state, action) => state + action.payload)
136+
.addCase('increment', state => state + 1)
137+
.addCase(decrement, (state, action) => state - action.payload)
138+
)
139+
).toThrowErrorMatchingInlineSnapshot(
140+
`"addCase cannot be called with two reducers for the same action type"`
141+
)
142+
})
143+
})
77144
})
78145

79146
function behavesLikeReducer(todosReducer: TodosReducer) {

src/createReducer.ts

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
import createNextState, { Draft } from 'immer'
22
import { AnyAction, Action, Reducer } from 'redux'
3+
import {
4+
executeReducerBuilderCallback,
5+
ActionReducerMapBuilder
6+
} from './mapBuilders'
37

48
/**
59
* Defines a mapping from action types to corresponding action object shapes.
@@ -51,7 +55,38 @@ export type CaseReducers<S, AS extends Actions> = {
5155
export function createReducer<
5256
S,
5357
CR extends CaseReducers<S, any> = CaseReducers<S, any>
54-
>(initialState: S, actionsMap: CR): Reducer<S> {
58+
>(initialState: S, actionsMap: CR): Reducer<S>
59+
/**
60+
* A utility function that allows defining a reducer as a mapping from action
61+
* type to *case reducer* functions that handle these action types. The
62+
* reducer's initial state is passed as the first argument.
63+
*
64+
* The body of every case reducer is implicitly wrapped with a call to
65+
* `produce()` from the [immer](https://github.com/mweststrate/immer) library.
66+
* This means that rather than returning a new state object, you can also
67+
* mutate the passed-in state object directly; these mutations will then be
68+
* automatically and efficiently translated into copies, giving you both
69+
* convenience and immutability.
70+
* @param initialState The initial state to be returned by the reducer.
71+
* @param builderCallback A callback that receives a *builder* object to define
72+
* case reducers via calls to `builder.addCase(actionCreatorOrType, reducer)`.
73+
*/
74+
export function createReducer<S>(
75+
initialState: S,
76+
builderCallback: (builder: ActionReducerMapBuilder<S>) => void
77+
): Reducer<S>
78+
79+
export function createReducer<S>(
80+
initialState: S,
81+
mapOrBuilderCallback:
82+
| CaseReducers<S, any>
83+
| ((builder: ActionReducerMapBuilder<S>) => void)
84+
): Reducer<S> {
85+
let actionsMap =
86+
typeof mapOrBuilderCallback === 'function'
87+
? executeReducerBuilderCallback(mapOrBuilderCallback)
88+
: mapOrBuilderCallback
89+
5590
return function(state = initialState, action): S {
5691
// @ts-ignore createNextState() produces an Immutable<Draft<S>> rather
5792
// than an Immutable<S>, and TypeScript cannot find out how to reconcile

src/createSlice.test.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,57 @@ describe('createSlice', () => {
105105

106106
expect(result).toBe(15)
107107
})
108+
109+
describe('alternative builder callback for extraReducers', () => {
110+
const increment = createAction<number, 'increment'>('increment')
111+
112+
test('can be used with actionCreators', () => {
113+
const slice = createSlice({
114+
name: 'counter',
115+
initialState: 0,
116+
reducers: {},
117+
extraReducers: builder =>
118+
builder.addCase(
119+
increment,
120+
(state, action) => state + action.payload
121+
)
122+
})
123+
expect(slice.reducer(0, increment(5))).toBe(5)
124+
})
125+
126+
test('can be used with string action types', () => {
127+
const slice = createSlice({
128+
name: 'counter',
129+
initialState: 0,
130+
reducers: {},
131+
extraReducers: builder =>
132+
builder.addCase(
133+
'increment',
134+
(state, action: { type: 'increment'; payload: number }) =>
135+
state + action.payload
136+
)
137+
})
138+
expect(slice.reducer(0, increment(5))).toBe(5)
139+
})
140+
141+
test('prevents the same action type from being specified twice', () => {
142+
expect(() =>
143+
createSlice({
144+
name: 'counter',
145+
initialState: 0,
146+
reducers: {},
147+
extraReducers: builder =>
148+
builder
149+
.addCase('increment', state => state + 1)
150+
.addCase('increment', state => state + 1)
151+
})
152+
).toThrowErrorMatchingInlineSnapshot(
153+
`"addCase cannot be called with two reducers for the same action type"`
154+
)
155+
})
156+
157+
// for further tests, see the test of createReducer that goes way more into depth on this
158+
})
108159
})
109160

110161
describe('behaviour with enhanced case reducers', () => {

src/createSlice.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@ import {
88
ActionCreatorWithPreparedPayload
99
} from './createAction'
1010
import { createReducer, CaseReducers, CaseReducer } from './createReducer'
11+
import {
12+
ActionReducerMapBuilder,
13+
executeReducerBuilderCallback
14+
} from './mapBuilders'
1115

1216
/**
1317
* An action creator atttached to a slice.
@@ -72,8 +76,12 @@ export interface CreateSliceOptions<
7276
* A mapping from action types to action-type-specific *case reducer*
7377
* functions. These reducers should have existing action types used
7478
* as the keys, and action creators will _not_ be generated.
79+
* Alternatively, a callback that receives a *builder* object to define
80+
* case reducers via calls to `builder.addCase(actionCreatorOrType, reducer)`.
7581
*/
76-
extraReducers?: CaseReducers<NoInfer<State>, any>
82+
extraReducers?:
83+
| CaseReducers<NoInfer<State>, any>
84+
| ((builder: ActionReducerMapBuilder<NoInfer<State>>) => void)
7785
}
7886

7987
type PayloadActions<Types extends keyof any = string> = Record<
@@ -201,7 +209,13 @@ export function createSlice<
201209
throw new Error('`name` is a required option for createSlice')
202210
}
203211
const reducers = options.reducers || {}
204-
const extraReducers = options.extraReducers || {}
212+
const extraReducers =
213+
typeof options.extraReducers === 'undefined'
214+
? {}
215+
: typeof options.extraReducers === 'function'
216+
? executeReducerBuilderCallback(options.extraReducers)
217+
: options.extraReducers
218+
205219
const reducerNames = Object.keys(reducers)
206220

207221
const sliceCaseReducersByName: Record<string, CaseReducer> = {}

src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,4 @@ export * from './createReducer'
88
export * from './createSlice'
99
export * from './serializableStateInvariantMiddleware'
1010
export * from './getDefaultMiddleware'
11+
export { ActionReducerMapBuilder } from './mapBuilders'

src/mapBuilders.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { Action } from 'redux'
2+
import { CaseReducer, CaseReducers } from './createReducer'
3+
4+
export interface TypedActionCreator<Type extends string> {
5+
(...args: any[]): Action<Type>
6+
type: Type
7+
}
8+
9+
/**
10+
* A builder for an action <-> reducer map.
11+
*/
12+
export interface ActionReducerMapBuilder<State> {
13+
/**
14+
* Add a case reducer for actions created by this action creator.
15+
* @param actionCreator
16+
* @param reducer
17+
*/
18+
addCase<ActionCreator extends TypedActionCreator<string>>(
19+
actionCreator: ActionCreator,
20+
reducer: CaseReducer<State, ReturnType<ActionCreator>>
21+
): ActionReducerMapBuilder<State>
22+
/**
23+
* Add a case reducer for actions with the specified type.
24+
* @param type
25+
* @param reducer
26+
*/
27+
addCase<Type extends string, A extends Action<Type>>(
28+
type: Type,
29+
reducer: CaseReducer<State, A>
30+
): ActionReducerMapBuilder<State>
31+
}
32+
33+
export function executeReducerBuilderCallback<S>(
34+
builderCallback: (builder: ActionReducerMapBuilder<S>) => void
35+
): CaseReducers<S, any> {
36+
const actionsMap: CaseReducers<S, any> = {}
37+
const builder = {
38+
addCase(
39+
typeOrActionCreator: string | TypedActionCreator<any>,
40+
reducer: CaseReducer<S>
41+
) {
42+
const type =
43+
typeof typeOrActionCreator === 'string'
44+
? typeOrActionCreator
45+
: typeOrActionCreator.type
46+
if (type in actionsMap) {
47+
throw new Error(
48+
'addCase cannot be called with two reducers for the same action type'
49+
)
50+
}
51+
actionsMap[type] = reducer
52+
return builder
53+
}
54+
}
55+
builderCallback(builder)
56+
return actionsMap
57+
}

type-tests/files/createReducer.typetest.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { Reducer } from 'redux'
2-
import { createReducer } from '../../src'
2+
import { createReducer, createAction, ActionReducerMapBuilder } from '../../src'
33

44
function expectType<T>(p: T) {}
55

@@ -63,3 +63,16 @@ function expectType<T>(p: T) {}
6363
}
6464
})
6565
}
66+
67+
/** Test: alternative builder callback for actionMap */
68+
{
69+
const increment = createAction<number, 'increment'>('increment')
70+
71+
const reducer = createReducer(0, builder =>
72+
expectType<ActionReducerMapBuilder<number>>(builder)
73+
)
74+
75+
expectType<number>(reducer(0, increment(5)))
76+
// typings:expect-error
77+
expectType<string>(reducer(0, increment(5)))
78+
}

type-tests/files/createSlice.typetest.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
import { AnyAction, Reducer, Action } from 'redux'
2-
import { createSlice, PayloadAction, createAction } from '../../src'
2+
import {
3+
createSlice,
4+
PayloadAction,
5+
createAction,
6+
ActionReducerMapBuilder
7+
} from '../../src'
38

49
function expectType<T>(t: T) {
510
return t
@@ -272,3 +277,15 @@ function expectType<T>(t: T) {
272277
expectType<string>(x.payload)
273278
}
274279
}
280+
281+
/** Test: alternative builder callback for extraReducers */
282+
{
283+
createSlice({
284+
name: 'test',
285+
initialState: 0,
286+
reducers: {},
287+
extraReducers: builder => {
288+
expectType<ActionReducerMapBuilder<number>>(builder)
289+
}
290+
})
291+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { executeReducerBuilderCallback } from 'src/mapBuilders'
2+
import { createAction, CaseReducers } from 'src'
3+
4+
function expectType<T>(t: T) {
5+
return t
6+
}
7+
8+
/** Test: alternative builder callback for actionMap */
9+
{
10+
const increment = createAction<number, 'increment'>('increment')
11+
const decrement = createAction<number, 'decrement'>('decrement')
12+
13+
executeReducerBuilderCallback<number>(builder => {
14+
builder.addCase(increment, (state, action) => {
15+
expectType<number>(state)
16+
expectType<{ type: 'increment'; payload: number }>(action)
17+
// typings:expect-error
18+
expectType<string>(state)
19+
// typings:expect-error
20+
expectType<{ type: 'increment'; payload: string }>(action)
21+
// typings:expect-error
22+
expectType<{ type: 'decrement'; payload: number }>(action)
23+
})
24+
25+
builder.addCase('increment', (state, action) => {
26+
expectType<number>(state)
27+
expectType<{ type: 'increment' }>(action)
28+
// typings:expect-error
29+
expectType<{ type: 'decrement' }>(action)
30+
// typings:expect-error - this cannot be inferred and has to be manually specified
31+
expectType<{ type: 'increment'; payload: number }>(action)
32+
})
33+
34+
builder.addCase(
35+
increment,
36+
(state, action: ReturnType<typeof increment>) => state
37+
)
38+
// typings:expect-error
39+
builder.addCase(
40+
increment,
41+
(state, action: ReturnType<typeof decrement>) => state
42+
)
43+
44+
builder.addCase(
45+
'increment',
46+
(state, action: ReturnType<typeof increment>) => state
47+
)
48+
// typings:expect-error
49+
builder.addCase(
50+
'decrement',
51+
(state, action: ReturnType<typeof increment>) => state
52+
)
53+
})
54+
}

0 commit comments

Comments
 (0)