Skip to content

Commit

Permalink
alternative callback-builder-style notation for actionsMap (#262)
Browse files Browse the repository at this point in the history
* alternative callback-builder-style notation for actionsMap

* add alternative api for extraReducers, move shared type tests to mapBuilders.typetest.ts

* add/update docblocks
  • Loading branch information
phryneas authored and markerikson committed Dec 1, 2019
1 parent 5eb1cc7 commit 1fc2f36
Show file tree
Hide file tree
Showing 9 changed files with 315 additions and 6 deletions.
69 changes: 68 additions & 1 deletion src/createReducer.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { createReducer, CaseReducer } from './createReducer'
import { PayloadAction } from './createAction'
import { PayloadAction, createAction } from './createAction'
import { Reducer } from 'redux'

interface Todo {
Expand Down Expand Up @@ -74,6 +74,73 @@ describe('createReducer', () => {

behavesLikeReducer(todosReducer)
})

describe('alternative builder callback for actionMap', () => {
const increment = createAction<number, 'increment'>('increment')
const decrement = createAction<number, 'decrement'>('decrement')

test('can be used with ActionCreators', () => {
const reducer = createReducer(0, builder =>
builder
.addCase(increment, (state, action) => state + action.payload)
.addCase(decrement, (state, action) => state - action.payload)
)
expect(reducer(0, increment(5))).toBe(5)
expect(reducer(5, decrement(5))).toBe(0)
})
test('can be used with string types', () => {
const reducer = createReducer(0, builder =>
builder
.addCase(
'increment',
(state, action: { type: 'increment'; payload: number }) =>
state + action.payload
)
.addCase(
'decrement',
(state, action: { type: 'decrement'; payload: number }) =>
state - action.payload
)
)
expect(reducer(0, increment(5))).toBe(5)
expect(reducer(5, decrement(5))).toBe(0)
})
test('can be used with ActionCreators and string types combined', () => {
const reducer = createReducer(0, builder =>
builder
.addCase(increment, (state, action) => state + action.payload)
.addCase(
'decrement',
(state, action: { type: 'decrement'; payload: number }) =>
state - action.payload
)
)
expect(reducer(0, increment(5))).toBe(5)
expect(reducer(5, decrement(5))).toBe(0)
})
test('will throw if the same type is used twice', () => {
expect(() =>
createReducer(0, builder =>
builder
.addCase(increment, (state, action) => state + action.payload)
.addCase(increment, (state, action) => state + action.payload)
.addCase(decrement, (state, action) => state - action.payload)
)
).toThrowErrorMatchingInlineSnapshot(
`"addCase cannot be called with two reducers for the same action type"`
)
expect(() =>
createReducer(0, builder =>
builder
.addCase(increment, (state, action) => state + action.payload)
.addCase('increment', state => state + 1)
.addCase(decrement, (state, action) => state - action.payload)
)
).toThrowErrorMatchingInlineSnapshot(
`"addCase cannot be called with two reducers for the same action type"`
)
})
})
})

function behavesLikeReducer(todosReducer: TodosReducer) {
Expand Down
37 changes: 36 additions & 1 deletion src/createReducer.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import createNextState, { Draft } from 'immer'
import { AnyAction, Action, Reducer } from 'redux'
import {
executeReducerBuilderCallback,
ActionReducerMapBuilder
} from './mapBuilders'

/**
* Defines a mapping from action types to corresponding action object shapes.
Expand Down Expand Up @@ -51,7 +55,38 @@ export type CaseReducers<S, AS extends Actions> = {
export function createReducer<
S,
CR extends CaseReducers<S, any> = CaseReducers<S, any>
>(initialState: S, actionsMap: CR): Reducer<S> {
>(initialState: S, actionsMap: CR): Reducer<S>
/**
* A utility function that allows defining a reducer as a mapping from action
* type to *case reducer* functions that handle these action types. The
* reducer's initial state is passed as the first argument.
*
* The body of every case reducer is implicitly wrapped with a call to
* `produce()` from the [immer](https://github.com/mweststrate/immer) library.
* This means that rather than returning a new state object, you can also
* mutate the passed-in state object directly; these mutations will then be
* automatically and efficiently translated into copies, giving you both
* convenience and immutability.
* @param initialState The initial state to be returned by the reducer.
* @param builderCallback A callback that receives a *builder* object to define
* case reducers via calls to `builder.addCase(actionCreatorOrType, reducer)`.
*/
export function createReducer<S>(
initialState: S,
builderCallback: (builder: ActionReducerMapBuilder<S>) => void
): Reducer<S>

export function createReducer<S>(
initialState: S,
mapOrBuilderCallback:
| CaseReducers<S, any>
| ((builder: ActionReducerMapBuilder<S>) => void)
): Reducer<S> {
let actionsMap =
typeof mapOrBuilderCallback === 'function'
? executeReducerBuilderCallback(mapOrBuilderCallback)
: mapOrBuilderCallback

return function(state = initialState, action): S {
// @ts-ignore createNextState() produces an Immutable<Draft<S>> rather
// than an Immutable<S>, and TypeScript cannot find out how to reconcile
Expand Down
51 changes: 51 additions & 0 deletions src/createSlice.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,57 @@ describe('createSlice', () => {

expect(result).toBe(15)
})

describe('alternative builder callback for extraReducers', () => {
const increment = createAction<number, 'increment'>('increment')

test('can be used with actionCreators', () => {
const slice = createSlice({
name: 'counter',
initialState: 0,
reducers: {},
extraReducers: builder =>
builder.addCase(
increment,
(state, action) => state + action.payload
)
})
expect(slice.reducer(0, increment(5))).toBe(5)
})

test('can be used with string action types', () => {
const slice = createSlice({
name: 'counter',
initialState: 0,
reducers: {},
extraReducers: builder =>
builder.addCase(
'increment',
(state, action: { type: 'increment'; payload: number }) =>
state + action.payload
)
})
expect(slice.reducer(0, increment(5))).toBe(5)
})

test('prevents the same action type from being specified twice', () => {
expect(() =>
createSlice({
name: 'counter',
initialState: 0,
reducers: {},
extraReducers: builder =>
builder
.addCase('increment', state => state + 1)
.addCase('increment', state => state + 1)
})
).toThrowErrorMatchingInlineSnapshot(
`"addCase cannot be called with two reducers for the same action type"`
)
})

// for further tests, see the test of createReducer that goes way more into depth on this
})
})

describe('behaviour with enhanced case reducers', () => {
Expand Down
18 changes: 16 additions & 2 deletions src/createSlice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ import {
ActionCreatorWithPreparedPayload
} from './createAction'
import { createReducer, CaseReducers, CaseReducer } from './createReducer'
import {
ActionReducerMapBuilder,
executeReducerBuilderCallback
} from './mapBuilders'

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

type PayloadActions<Types extends keyof any = string> = Record<
Expand Down Expand Up @@ -201,7 +209,13 @@ export function createSlice<
throw new Error('`name` is a required option for createSlice')
}
const reducers = options.reducers || {}
const extraReducers = options.extraReducers || {}
const extraReducers =
typeof options.extraReducers === 'undefined'
? {}
: typeof options.extraReducers === 'function'
? executeReducerBuilderCallback(options.extraReducers)
: options.extraReducers

const reducerNames = Object.keys(reducers)

const sliceCaseReducersByName: Record<string, CaseReducer> = {}
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,4 @@ export * from './createReducer'
export * from './createSlice'
export * from './serializableStateInvariantMiddleware'
export * from './getDefaultMiddleware'
export { ActionReducerMapBuilder } from './mapBuilders'
57 changes: 57 additions & 0 deletions src/mapBuilders.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { Action } from 'redux'
import { CaseReducer, CaseReducers } from './createReducer'

export interface TypedActionCreator<Type extends string> {
(...args: any[]): Action<Type>
type: Type
}

/**
* A builder for an action <-> reducer map.
*/
export interface ActionReducerMapBuilder<State> {
/**
* Add a case reducer for actions created by this action creator.
* @param actionCreator
* @param reducer
*/
addCase<ActionCreator extends TypedActionCreator<string>>(
actionCreator: ActionCreator,
reducer: CaseReducer<State, ReturnType<ActionCreator>>
): ActionReducerMapBuilder<State>
/**
* Add a case reducer for actions with the specified type.
* @param type
* @param reducer
*/
addCase<Type extends string, A extends Action<Type>>(
type: Type,
reducer: CaseReducer<State, A>
): ActionReducerMapBuilder<State>
}

export function executeReducerBuilderCallback<S>(
builderCallback: (builder: ActionReducerMapBuilder<S>) => void
): CaseReducers<S, any> {
const actionsMap: CaseReducers<S, any> = {}
const builder = {
addCase(
typeOrActionCreator: string | TypedActionCreator<any>,
reducer: CaseReducer<S>
) {
const type =
typeof typeOrActionCreator === 'string'
? typeOrActionCreator
: typeOrActionCreator.type
if (type in actionsMap) {
throw new Error(
'addCase cannot be called with two reducers for the same action type'
)
}
actionsMap[type] = reducer
return builder
}
}
builderCallback(builder)
return actionsMap
}
15 changes: 14 additions & 1 deletion type-tests/files/createReducer.typetest.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Reducer } from 'redux'
import { createReducer } from '../../src'
import { createReducer, createAction, ActionReducerMapBuilder } from '../../src'

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

Expand Down Expand Up @@ -63,3 +63,16 @@ function expectType<T>(p: T) {}
}
})
}

/** Test: alternative builder callback for actionMap */
{
const increment = createAction<number, 'increment'>('increment')

const reducer = createReducer(0, builder =>
expectType<ActionReducerMapBuilder<number>>(builder)
)

expectType<number>(reducer(0, increment(5)))
// typings:expect-error
expectType<string>(reducer(0, increment(5)))
}
19 changes: 18 additions & 1 deletion type-tests/files/createSlice.typetest.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { AnyAction, Reducer, Action } from 'redux'
import { createSlice, PayloadAction, createAction } from '../../src'
import {
createSlice,
PayloadAction,
createAction,
ActionReducerMapBuilder
} from '../../src'

function expectType<T>(t: T) {
return t
Expand Down Expand Up @@ -272,3 +277,15 @@ function expectType<T>(t: T) {
expectType<string>(x.payload)
}
}

/** Test: alternative builder callback for extraReducers */
{
createSlice({
name: 'test',
initialState: 0,
reducers: {},
extraReducers: builder => {
expectType<ActionReducerMapBuilder<number>>(builder)
}
})
}
54 changes: 54 additions & 0 deletions type-tests/files/mapBuilders.typetest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { executeReducerBuilderCallback } from 'src/mapBuilders'
import { createAction, CaseReducers } from 'src'

function expectType<T>(t: T) {
return t
}

/** Test: alternative builder callback for actionMap */
{
const increment = createAction<number, 'increment'>('increment')
const decrement = createAction<number, 'decrement'>('decrement')

executeReducerBuilderCallback<number>(builder => {
builder.addCase(increment, (state, action) => {
expectType<number>(state)
expectType<{ type: 'increment'; payload: number }>(action)
// typings:expect-error
expectType<string>(state)
// typings:expect-error
expectType<{ type: 'increment'; payload: string }>(action)
// typings:expect-error
expectType<{ type: 'decrement'; payload: number }>(action)
})

builder.addCase('increment', (state, action) => {
expectType<number>(state)
expectType<{ type: 'increment' }>(action)
// typings:expect-error
expectType<{ type: 'decrement' }>(action)
// typings:expect-error - this cannot be inferred and has to be manually specified
expectType<{ type: 'increment'; payload: number }>(action)
})

builder.addCase(
increment,
(state, action: ReturnType<typeof increment>) => state
)
// typings:expect-error
builder.addCase(
increment,
(state, action: ReturnType<typeof decrement>) => state
)

builder.addCase(
'increment',
(state, action: ReturnType<typeof increment>) => state
)
// typings:expect-error
builder.addCase(
'decrement',
(state, action: ReturnType<typeof increment>) => state
)
})
}

0 comments on commit 1fc2f36

Please sign in to comment.