diff --git a/README.md b/README.md index d4d1cc2..557c543 100644 --- a/README.md +++ b/README.md @@ -134,6 +134,40 @@ class MyAppComponent { } ``` +Using typed actions in conjunction with `switchReduce` allow you to write reducers in a more type safety way. + +```ts +// counter.ts +import { ActionReducer, TypedAction, switchReduce } from '@ngrx/store'; + +export class AddAction implements TypedAction { + readonly type = 'ADD'; + constructor(public readonly payload: number) {} +} +export class SubtractAction implements TypedAction { + readonly type = 'SUBTRACT'; + constructor(public readonly payload: number) {} +} +export class ResetAction implements TypedAction { + readonly type = 'RESET'; + readonly payload: any; + constructor() {} +} + +export const counterReducer: ActionReducer = + (state: number = 0, action: TypedAction) => + switchReduce(state, action) + .byClass(AddAction, (num: number) => { + return state + num; + }) + .byClass(SubtractAction, (num: number) => { + return state - num; + }) + .byClass(ResetAction, () => { + return 0; + }) + .reduce(); +``` ## Contributing Please read [contributing guidelines here](https://github.com/ngrx/store/blob/master/CONTRIBUTING.md). diff --git a/spec/switch-reduce.spec.ts b/spec/switch-reduce.spec.ts new file mode 100644 index 0000000..0a466ff --- /dev/null +++ b/spec/switch-reduce.spec.ts @@ -0,0 +1,88 @@ +import {switchReduce} from '../src/utils'; +import {TypedAction} from '../src/dispatcher'; +interface TestState { + num: number; +} + +class AddNumberAction implements TypedAction { + type: 'ADD_NUMBER'; + constructor(public payload: number) {} +} + +class SubtractNumberAction implements TypedAction { + type: 'SUBTRACT_NUMBER'; + constructor(public payload: number) {} +} + +let testState: TestState; +describe('switchReduce', () => { + beforeEach(() => { + testState = { + num: 1 + }; + }); + + it('should return initial state with no cases and no default', () => { + const runSpy = jasmine.createSpy('spy'); + const payload = 1; + + switchReduce(testState, new AddNumberAction(payload)).reduce(); + + expect(runSpy).not.toHaveBeenCalled(); + }); + + it('should take default if nothing else specified', () => { + const newState = switchReduce(testState, new AddNumberAction(1)) + .reduce(() => ({ + num: 5 + })); + + expect(newState.num).toBe(5); + }); + + it('should take default if nothing else matches', () => { + const runSpy = jasmine.createSpy('spy'); + + const newState = switchReduce(testState, new AddNumberAction(1)) + .byClass(SubtractNumberAction, runSpy) + .byType('NOT_EXISTING', runSpy) + .reduce(() => ({ + num: 5 + })); + + expect(newState.num).toBe(5); + expect(runSpy).not.toHaveBeenCalled(); + }); + + it('should execute run function only once', () => { + const runSpy = jasmine.createSpy('spy'); + const payload = 1; + + switchReduce(testState, new AddNumberAction(payload)) + .byClass(AddNumberAction, runSpy) + .byClasses([AddNumberAction, SubtractNumberAction], runSpy) + .byType('ADD_NUMBER', runSpy) + .byTypes(['ADD_NUMBER', 'SUBTRACT_NUMBER'], runSpy) + .reduce(runSpy); + + expect(runSpy).toHaveBeenCalledWith(payload, jasmine.any(AddNumberAction), jasmine.anything()); + expect(runSpy.calls.count()).toBe(1); + }); + + it('should execute same byClasses for each action', () => { + const applySwitchReduce = (state: TestState, action: TypedAction) => + switchReduce(state, action) + .byClasses([AddNumberAction, SubtractNumberAction], (payload: number, innerAction: TypedAction) => { + const addend: number = innerAction instanceof AddNumberAction ? payload : -payload; + return Object.assign({}, state, { + num: state.num + addend + }); + }) + .reduce(); + + const newState1 = applySwitchReduce(testState, new AddNumberAction(1)); + const newState2 = applySwitchReduce(newState1, new SubtractNumberAction(2)); + expect(newState1.num).toBe(2); + expect(newState2.num).toBe(0); + }); +}); diff --git a/src/dispatcher.ts b/src/dispatcher.ts index 98934b9..e3216df 100644 --- a/src/dispatcher.ts +++ b/src/dispatcher.ts @@ -5,6 +5,10 @@ export interface Action { payload?: any; } +export interface TypedAction extends Action { + payload: T; +} + export class Dispatcher extends BehaviorSubject { static INIT = '@ngrx/store/init'; diff --git a/src/utils.ts b/src/utils.ts index 1710f55..8500d9b 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,4 +1,5 @@ import {ActionReducer} from './reducer'; +import {TypedAction} from './dispatcher'; export function combineReducers(reducers: any): ActionReducer { const reducerKeys = Object.keys(reducers); @@ -28,3 +29,50 @@ export function combineReducers(reducers: any): ActionReducer { return hasChanged ? nextState : state; }; } + +export type SwitchReduceRun = (payload: P, action?: TypedAction

, state?: S) => S; + +export class SwitchReduceBuilder { + private newState: S; + + constructor(private state: S, private action: TypedAction) { + this.newState = state; + } + + byClass

(actionConstructor: {new(P): TypedAction

; }, run: SwitchReduceRun): SwitchReduceBuilder { + if (this.newState === this.state && this.action instanceof actionConstructor) { + this.newState = run(this.action.payload, this.action, this.state); + } + return this; + } + + byClasses(actionConstructors: {new(a: any): TypedAction; }[], run: SwitchReduceRun): SwitchReduceBuilder { + actionConstructors.forEach((actionConstructor) => + this.byClass(actionConstructor, run)); + return this; + } + + byType(type: string, run: SwitchReduceRun): SwitchReduceBuilder { + if (this.newState === this.state && type === this.action.type) { + this.newState = run(this.action.payload, this.action, this.state); + } + return this; + } + + byTypes(types: string[], run: SwitchReduceRun): SwitchReduceBuilder { + types.forEach((type) => + this.byType(type, run)); + return this; + } + + reduce(defaultRun?: SwitchReduceRun): S { + if (defaultRun instanceof Function && this.newState === this.state) { + this.newState = defaultRun(this.action.payload, null, this.state); + } + return this.newState; + } +} + +export function switchReduce(state: S, action: TypedAction): SwitchReduceBuilder { + return new SwitchReduceBuilder(state, action); +}