├── .editorconfig ├── .eslintrc.yml ├── .gitignore ├── .npmignore ├── .travis.yml ├── LICENSE ├── README.md ├── commitlint.config.js ├── package-lock.json ├── package.json ├── src ├── create-action.test.ts ├── create-action.ts ├── create-async-actions.test.ts ├── create-async-actions.ts ├── example.ts ├── handle-action.test.ts ├── handle-action.ts ├── index.test.ts ├── index.ts ├── reduce-reducers.test.ts └── reduce-reducers.ts └── tsconfig.json /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | indent_size = 2 7 | indent_style = space 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.md] 12 | trim_trailing_whitespace = false 13 | -------------------------------------------------------------------------------- /.eslintrc.yml: -------------------------------------------------------------------------------- 1 | extends: 2 | - airbnb-base 3 | - 'plugin:@typescript-eslint/recommended' 4 | parser: '@typescript-eslint/parser' 5 | plugins: 6 | - '@typescript-eslint' 7 | settings: 8 | import/resolver: 9 | node: 10 | extensions: 11 | - .ts 12 | import/parsers: 13 | '@typescript-eslint/parser': [.ts] 14 | rules: 15 | no-undef: off 16 | space-infix-ops: off 17 | import/extensions: [error, ignorePackages, { 18 | js: never, 19 | }] 20 | import/named: off 21 | import/prefer-default-export: off 22 | '@typescript-eslint/indent': [error, 2] 23 | '@typescript-eslint/no-explicit-any': off 24 | overrides: 25 | - files: '**/*.test.ts' 26 | rules: 27 | import/no-extraneous-dependencies: off 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | coverage 2 | dist 3 | node_modules 4 | *.log 5 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | coverage 2 | node_modules 3 | *.log 4 | *.ignore.* 5 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - '11' 4 | - '10' 5 | - '9' 6 | - '8' 7 | - 'stable' 8 | script: 9 | - npm run build 10 | - npx codecov 11 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2018-2019 Kenneth Powers (https://knpw.rs) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # DEPRECATION NOTICE 2 | 3 | There is now an official library called [`@reduxjs/toolkit`] which does 4 | everything this library set out to solve and more! I have decided to deprecate 5 | `redux-ts-utils` in favor of [`@reduxjs/toolkit`]. 6 | 7 | # redux-ts-utils 8 | 9 | [![Dependency Status](https://img.shields.io/david/knpwrs/redux-ts-utils.svg)](https://david-dm.org/knpwrs/redux-ts-utils) 10 | [![devDependency Status](https://img.shields.io/david/dev/knpwrs/redux-ts-utils.svg)](https://david-dm.org/knpwrs/redux-ts-utils#info=devDependencies) 11 | [![Greenkeeper badge](https://badges.greenkeeper.io/knpwrs/redux-ts-utils.svg)](https://greenkeeper.io/) 12 | [![Build Status](https://img.shields.io/travis/knpwrs/redux-ts-utils.svg)](https://travis-ci.org/knpwrs/redux-ts-utils) 13 | [![Coverage](https://img.shields.io/codecov/c/github/knpwrs/redux-ts-utils.svg)](https://codecov.io/gh/knpwrs/redux-ts-utils) 14 | [![Npm Version](https://img.shields.io/npm/v/redux-ts-utils.svg)](https://www.npmjs.com/package/redux-ts-utils) 15 | [![TypeScript 3](https://img.shields.io/badge/TypeScript-3-blue.svg)](http://shields.io/) 16 | [![License](https://img.shields.io/badge/license-MIT-blue.svg)](https://opensource.org/licenses/MIT) 17 | [![Badges](https://img.shields.io/badge/badges-9-orange.svg)](http://shields.io/) 18 | 19 | Everything you need to create type-safe applications with Redux! [Flux Standard 20 | Action][FSA] compliant. 21 | 22 | ## Example Usage 23 | 24 | ```ts 25 | import { createStore, Store } from 'redux'; 26 | import { createAction, handleAction, reduceReducers } from 'redux-ts-utils'; 27 | 28 | // Actions 29 | 30 | const increment = createAction('increment'); 31 | const decrement = createAction('decrement'); 32 | const add = createAction('add'); 33 | const override = createAction('override'); 34 | 35 | // Reducer 36 | 37 | type State = { 38 | readonly counter: number, 39 | }; 40 | 41 | const initialState: State = { 42 | counter: 0, 43 | }; 44 | 45 | const reducer = reduceReducers([ 46 | handleAction(increment, (state) => { 47 | state.counter += 1; 48 | }), 49 | handleAction(decrement, (state) => { 50 | state.counter -= 1; 51 | }), 52 | handleAction(add, (state, { payload }) => { 53 | state.counter += payload; 54 | }), 55 | handleAction(override, (_, { payload }) => ({ 56 | counter: payload, 57 | })), 58 | ], initialState); 59 | 60 | // Store 61 | 62 | const store: Store = createStore(reducer); 63 | store.subscribe(() => console.log('New state!', store.getState())); 64 | 65 | // Go to town! 66 | 67 | store.dispatch(increment()); 68 | store.dispatch(increment()); 69 | store.dispatch(increment()); 70 | store.dispatch(decrement()); 71 | store.dispatch(add(10)); 72 | console.log('Final count!', store.getState().counter); // 12 73 | ``` 74 | 75 | Everything you see above is 100% type safe! The action creators only take 76 | specified types and both the state and action payloads passed to the reducers 77 | are strongly typed. Most types are inferred so you don't need to think about it 78 | most of the time, but your build will still fail if you do something you 79 | shouldn't. 80 | 81 | The reducers are automatically run with [`immer`], which will track any 82 | "mutations" you make and return the optimally-immutably-updated state object. 83 | 84 | You can run the above example by cloning this repository and running the 85 | following commands: 86 | 87 | ```sh 88 | npm install 89 | npm run example 90 | ``` 91 | 92 | There is also an [example React app][ex] available on GitHub which you can also 93 | see [running on CodeSandbox][cs]. 94 | 95 | ## API 96 | 97 | This package exports a grand total of four functions. 98 | 99 | A lot of the generics for these functions can be inferred (see above example). 100 | The typings below provided are optimized for readability. 101 | 102 | ### `createAction(type: string, payloadCreator?(args: A) => T)` 103 | 104 | The `createAction` returns an action creator function (a function which returns 105 | an action object). The first argument should be a string representing the type 106 | of action being created, and the second argument is an optional payload creator 107 | function. The action objects returned by these action creators have two 108 | properties: `type` (a `string`) and `payload` (typed as `T`). 109 | 110 | Typically it is best to use the simplest signature for this function: 111 | 112 | ```ts 113 | const myActionCreator = createAction('MY_ACTION'); 114 | ``` 115 | 116 | The action creator function will be typed to take whatever you provide as a 117 | payload type. 118 | 119 | If your action creator needs to take arguments other than whatever your payload 120 | is typed as you can simply provide a typed payload creator function: 121 | 122 | ```ts 123 | // addThreeNumbers accepts three ordinal number aguments and has a number payload: 124 | const addThreeNumbers = createAction('ADD_THREE_NUMBERS', (a: number, b: number, c: number) => a + b + c); 125 | ``` 126 | 127 | If you need to customize the [SFP] `meta` property you can supply a second meta 128 | creator function: 129 | 130 | ```ts 131 | const addThreeNumbers = createAction( 132 | 'ADD_THREE_NUMBERS', 133 | // Create `payload` 134 | (a, b, c) => a + b + c, 135 | // Create `meta` 136 | (a, b, c) => `${a} + ${b} + ${c}`, 137 | ); 138 | ``` 139 | 140 | Note that the payload and meta creators must accept the same arguments, but can 141 | return different types. In the example above the payload creator takes three 142 | numbers and returns a number while the meta creator takes three numbers and 143 | returns a string. 144 | 145 | ### `handleAction(actionCreator, (state: Draft, payload) => void, initialState?: State)` 146 | 147 | The `handleAction` function returns a single reducer function. The first 148 | argument should be an action creator from the `createAction` function. The 149 | second argument should be a "mutation" function which takes the current state 150 | and the action. The third argument is an optional initial state argument. 151 | 152 | When provided with an action with a type that matches the type from 153 | `actionCreator` the mutation function will be run. The mutation function is 154 | automatically run with [`immer`] which will track all modifications you make to 155 | the incoming state object and return the optimally-immutably-updated new state 156 | object. [`immer`] will also provide you with a mapped type (`Draft`) of your 157 | state with all `readonly` modifiers removed (it will also remove `Readonly` 158 | mapped types and convert `ReadonlyArray`s to standard arrays). 159 | 160 | If your mutation function returns a value other than `undefined`, and does not mutate the 161 | incoming state object, that return value will become the new state instead. 162 | 163 | ### `reduceReducers(reducers: Reducer[], initialState?: S)` 164 | 165 | The `reduceReducers` function takes an array of reducer functions and an 166 | optional initial state value and returns a single reducer which runs all of the 167 | input reducers in sequence. 168 | 169 | ### `createAsyncActions(type: string, startPayloadCreator, successPayloadCreator, failPayloadCreator)` 170 | 171 | Oftentimes when working with sagas, thunks, or some other asynchronous, 172 | side-effecting middleware you need to create three actions which are named 173 | similarly. This is a convenience function which calls `createAction` three 174 | times for you. Consider the following example: 175 | 176 | ```ts 177 | import { noop } from 'lodash'; 178 | import { createAsyncActions } from 'redux-ts-utils'; 179 | 180 | type User = { name: string }; 181 | 182 | export const [ 183 | requestUsers, 184 | requestUsersSuccess, 185 | requestUsersFailure, 186 | ] = createAsyncActions('REQUEST_USERS', noop, (users: User[]) => users); 187 | 188 | requestUsers(); // returns action of type `REQUEST_USERS` 189 | requestUsersSuccess([{ name: 'knpwrs' }]); // returns action of type `REQUEST_USERS/SUCCESS` 190 | requestUsersError(); // returns action of type `REQUEST_USERS/ERROR` 191 | ``` 192 | 193 | The first argument is the action/triad name, and the second through third 194 | (optional) arguments are payload creators for the initial action, the success 195 | action, and the error action, respectively. `noop` is imported from lodash in 196 | order to be explicit that in this case the payload for `requestUsers` is 197 | `void`. You can just as easily use `() => {}` inline. The action creators infer 198 | their payload types from the supplied payload creators. See [the 199 | implementation](./src/create-async-actions.ts) for complete type information. 200 | 201 | 202 | ## Design Philosophy 203 | 204 | ### A Strong Emphasis on Type Safety 205 | 206 | Nothing should be stringly-typed. If you make a breaking change anywhere in 207 | your data layer the compiler should complain. 208 | 209 | ### Simplicity 210 | 211 | Whenever possible it is best to maintain strong safety; however, this can lead 212 | to extremely verbose code. For that reason this library strongly encourages 213 | type inference whenever possible. 214 | 215 | This library exports four functions and a handful of types. Everything you need 216 | is provided by one package. The API surface is very small and easy to grok. 217 | 218 | ### Not Too Opinionated 219 | 220 | `redux-ts-utils` provides TypeScript-friendly abstractions over the most 221 | commonly-repeated pieces of boilerplate present in Redux projects. It is not a 222 | complete framework abstracting all of Redux. It does not dictate or abstract 223 | how you write your selectors, how you handle asynchronous actions or side 224 | effects, how you create your store, or any other aspect of how you use Redux. 225 | This makes `redux-ts-utils` very non-opinionated compared to other Redux 226 | utility libraries. The closest thing to an opinion you will find in this 227 | library is that it ships with [`immer`]. The reason for this is that [`immer`] 228 | has proven to be the best method for dealing with immutable data structures in 229 | a way which is both type-safe and performant. On top of that, [`immer`], by its 230 | inclusion in [`redux-starter-kit`], has effectively been officially endorsed as 231 | the de facto solution for managing immutable state changes. Shipping with 232 | [`immer`] helps to maintain the goal of [simplicity](#simplicity) by reducing 233 | the necessary API surface for writing reducers and by ensuring type inference 234 | whenever possible. 235 | 236 | Setting up a redux store and middleware is typically a one-time task per 237 | project, so this library does not provide an abstraction for that. Likewise, 238 | [thunks] are simple but [sagas] are powerful, or maybe you like [promises] or 239 | [observables]. You should choose what works best for your project. Finally, 240 | given this library's strong emphasis on type safety it doesn't necessarily make 241 | sense to provide abstractions for creating selectors at the expense of type 242 | safety. 243 | 244 | ### A Note on Flux Standard Actions 245 | 246 | This library is compliant with [Flux Standard Actions][FSA]. That said, there 247 | is one important distinction with the way this library is typed that you should 248 | take note of of. 249 | 250 | The [FSA] docs state that the `payload` property is optional and _may_ have a 251 | value. This makes reducers a pain to write because TypeScript will enforce that 252 | you always check for the existence of the payload property in order to use the 253 | resulting actions. If you want to create an action that doesn't require a 254 | payload, the simplest (and most type-explicit) thing to do is to type the 255 | payload as `void`: 256 | 257 | ```ts 258 | const myAction = createAction('MY_ACTION'); 259 | ``` 260 | 261 | Even with this particular distinction, the actions created by this library are 262 | [FSA]-compliant. 263 | 264 | ## License 265 | 266 | **MIT** 267 | 268 | [FSA]: https://github.com/redux-utilities/flux-standard-action 269 | [`@reduxjs/toolkit`]: https://www.npmjs.com/package/@reduxjs/toolkit 270 | [`immer`]: https://github.com/mweststrate/immer "Create the next immutable state by mutating the current one" 271 | [`redux-starter-kit`]: https://www.npmjs.com/package/redux-starter-kit 272 | [cs]: https://codesandbox.io/s/github/knpwrs/redux-ts-utils-example-app 273 | [ex]: https://github.com/knpwrs/redux-ts-utils-example-app 274 | [observables]: https://github.com/redux-observable/redux-observable 275 | [promises]: https://github.com/redux-utilities/redux-promise 276 | [sagas]: https://github.com/redux-saga/redux-saga 277 | [thunks]: https://github.com/reduxjs/redux-thunk 278 | -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ['@commitlint/config-conventional'], 3 | }; 4 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "redux-ts-utils", 3 | "version": "4.0.0", 4 | "description": "Everything you need to create type-safe applications with Redux!", 5 | "main": "dist/commonjs/index.js", 6 | "module": "dist/es/index.js", 7 | "typings": "dist/es/index.d.ts", 8 | "scripts": { 9 | "commitmsg": "commitlint -E HUSKY_GIT_PARAMS", 10 | "prebuild": "npm run test", 11 | "build": "npm run build:commonjs && npm run build:es", 12 | "build:commonjs": "tsc --outDir dist/commonjs --target es5", 13 | "build:es": "tsc --outDir dist/es", 14 | "clean": "rimraf dist", 15 | "example": "ts-node ./src/example", 16 | "lint": "eslint --ext ts src/**/*", 17 | "pretest": "npm run example", 18 | "test": "jest --coverage", 19 | "test:watch": "jest --watch", 20 | "posttest": "npm run lint", 21 | "prepack": "npm run build", 22 | "release": "np" 23 | }, 24 | "jest": { 25 | "testEnvironment": "node", 26 | "transform": { 27 | "\\.ts$": "ts-jest" 28 | }, 29 | "testRegex": "\\.test\\.ts$", 30 | "moduleFileExtensions": [ 31 | "ts", 32 | "js" 33 | ] 34 | }, 35 | "repository": { 36 | "type": "git", 37 | "url": "git+https://github.com/knpwrs/redux-ts-utils.git" 38 | }, 39 | "keywords": [ 40 | "redux", 41 | "typescript", 42 | "utilities", 43 | "immer" 44 | ], 45 | "author": "Kenneth Powers (https://knpw.rs)", 46 | "license": "MIT", 47 | "bugs": { 48 | "url": "https://github.com/knpwrs/redux-ts-utils/issues" 49 | }, 50 | "homepage": "https://github.com/knpwrs/redux-ts-utils#readme", 51 | "peerDependencies": { 52 | "redux": "^4.0.1" 53 | }, 54 | "dependencies": { 55 | "immer": "^2.0.0" 56 | }, 57 | "devDependencies": { 58 | "@commitlint/cli": "^7.2.1", 59 | "@commitlint/config-conventional": "^7.1.2", 60 | "@types/jest": "^24.0.4", 61 | "@types/node": "^11.9.3", 62 | "@typescript-eslint/eslint-plugin": "^1.2.0", 63 | "@typescript-eslint/parser": "^1.2.0", 64 | "codecov": "^3.1.0", 65 | "eslint": "^5.9.0", 66 | "eslint-config-airbnb-base": "^13.1.0", 67 | "eslint-plugin-import": "^2.14.0", 68 | "husky": "^1.2.0", 69 | "jest": "^24.1.0", 70 | "np": "^4.0.2", 71 | "redux": "^4.0.1", 72 | "rimraf": "^2.6.2", 73 | "ts-jest": "^24.0.0", 74 | "ts-node": "^8.0.2", 75 | "typescript": "^3.3.1" 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/create-action.test.ts: -------------------------------------------------------------------------------- 1 | import { bindActionCreators } from 'redux'; 2 | import createAction from './create-action'; 3 | 4 | test('creates actions with constant payload creator', () => { 5 | const ac = createAction('foo1', () => 'bar'); 6 | expect(ac.type).toBe('foo1'); 7 | expect(ac()).toEqual({ 8 | type: 'foo1', 9 | payload: 'bar', 10 | }); 11 | }); 12 | 13 | test('creates actions with untyped identity payload creator', () => { 14 | const ac = createAction('foo2'); 15 | expect(ac.type).toBe('foo2'); 16 | expect(ac('bar')).toEqual({ 17 | type: 'foo2', 18 | payload: 'bar', 19 | }); 20 | }); 21 | 22 | test('creates actions with void identity payload creator', () => { 23 | const ac = createAction('void-action'); 24 | expect(ac.type).toBe('void-action'); 25 | expect(ac()).toEqual({ 26 | type: 'void-action', 27 | }); 28 | }); 29 | 30 | test('creates actions with void identity payload creator', () => { 31 | const ac = createAction('void-action-untyped'); 32 | expect(ac.type).toBe('void-action-untyped'); 33 | expect(ac()).toEqual({ 34 | type: 'void-action-untyped', 35 | }); 36 | }); 37 | 38 | test('creates actions with explicit payload creator', () => { 39 | const ac = createAction('foo3', num => `${num}`); 40 | expect(ac.type).toBe('foo3'); 41 | expect(ac(4)).toEqual({ 42 | type: 'foo3', 43 | payload: '4', 44 | }); 45 | }); 46 | 47 | test('creates actions with explicit payload creator (many args)', () => { 48 | const ac = createAction('foo4', num => `${num * 2}`); 49 | expect(ac.type).toBe('foo4'); 50 | expect(ac(4, true, 'bar')).toEqual({ 51 | type: 'foo4', 52 | payload: '8', 53 | }); 54 | }); 55 | 56 | test('meta customizer', () => { 57 | const ac = createAction('with-meta', s => s, s => s.toUpperCase()); 58 | expect(ac.type).toBe('with-meta'); 59 | expect(ac('foo')).toEqual({ 60 | type: 'with-meta', 61 | payload: 'foo', 62 | meta: 'FOO', 63 | }); 64 | }); 65 | 66 | test('generic inference', () => { 67 | const ac = createAction('generic-inference', (a: number, b: number, c: number) => a + b + c); 68 | expect(ac.type).toBe('generic-inference'); 69 | expect(ac(1, 2, 3)).toEqual({ 70 | type: 'generic-inference', 71 | payload: 6, 72 | }); 73 | }); 74 | 75 | test('generic inference with meta', () => { 76 | const ac = createAction( 77 | 'generic-inference-with-meta', 78 | // Create `payload` 79 | (a, b, c) => a + b + c, 80 | // Create `meta` 81 | (a, b, c) => `${a} + ${b} + ${c}`, 82 | ); 83 | expect(ac.type).toBe('generic-inference-with-meta'); 84 | expect(ac(1, 2, 3)).toEqual({ 85 | type: 'generic-inference-with-meta', 86 | payload: 6, 87 | meta: '1 + 2 + 3', 88 | }); 89 | }); 90 | 91 | test('error payload', () => { 92 | const ac = createAction('with-error'); 93 | expect(ac.type).toBe('with-error'); 94 | expect(ac(new Error('Rabble!'))).toEqual({ 95 | type: 'with-error', 96 | payload: new Error('Rabble!'), 97 | error: true, 98 | }); 99 | }); 100 | 101 | test('polymorphic payload', () => { 102 | const ac = createAction('with-polymorphic'); 103 | expect(ac.type).toBe('with-polymorphic'); 104 | expect(ac('foo')).toEqual({ 105 | type: 'with-polymorphic', 106 | payload: 'foo', 107 | }); 108 | expect(ac(new Error('Rabble!'))).toEqual({ 109 | type: 'with-polymorphic', 110 | payload: new Error('Rabble!'), 111 | error: true, 112 | }); 113 | }); 114 | 115 | test('works with bindActionCreators from redux', () => { 116 | const ac1 = createAction('foo5', () => 'baz'); 117 | expect(ac1.type).toBe('foo5'); 118 | const ac2 = createAction('bar5', s => s); 119 | expect(ac2.type).toBe('bar5'); 120 | const acs = bindActionCreators({ 121 | ac1, 122 | ac2, 123 | }, (action) => { 124 | expect(action.type).toBe('foo5'); 125 | return action; 126 | }); 127 | acs.ac1(); 128 | }); 129 | 130 | test('toString', () => { 131 | const ac = createAction('foo6'); 132 | expect(ac.type).toBe('foo6'); 133 | expect(ac.toString()).toBe('foo6'); 134 | }); 135 | -------------------------------------------------------------------------------- /src/create-action.ts: -------------------------------------------------------------------------------- 1 | import { Action } from 'redux'; 2 | 3 | export interface TsAction

extends Action { 4 | payload: P; 5 | error?: boolean; 6 | meta?: M; 7 | toString(): string; 8 | } 9 | 10 | export interface TsActionCreator

{ 11 | (...args: A): TsAction; 12 | type: string; 13 | } 14 | 15 | export type PayloadCreator = (...args: A) => P; 16 | export const identity = (...arg: T): T[0] => arg[0]; 17 | 18 | // eslint-disable-next-line arrow-parens 19 | export default ( 20 | type: string, 21 | pc: PayloadCreator = identity, 22 | mc?: PayloadCreator, 23 | ): TsActionCreator => { 24 | // Continue with creating an action creator 25 | const ac = (...args: A): TsAction => { 26 | const payload = pc(...args); 27 | 28 | const action: TsAction = { type, payload }; 29 | 30 | if (payload instanceof Error) { 31 | action.error = true; 32 | } 33 | 34 | if (mc) { 35 | action.meta = mc(...args); 36 | } 37 | 38 | return action; 39 | }; 40 | 41 | (ac as TsActionCreator).type = type; 42 | ac.toString = () => type; 43 | 44 | return ac as TsActionCreator; 45 | }; 46 | -------------------------------------------------------------------------------- /src/create-async-actions.test.ts: -------------------------------------------------------------------------------- 1 | import createAsyncActions from './create-async-actions'; 2 | 3 | test('creates a triad of identity action creators', () => { 4 | const [start, success, fail] = createAsyncActions('foo'); 5 | expect(start.type).toBe('foo'); 6 | expect(success.type).toBe('foo/SUCCESS'); 7 | expect(fail.type).toBe('foo/ERROR'); 8 | 9 | expect(start('foo')).toEqual({ 10 | type: 'foo', 11 | payload: 'foo', 12 | }); 13 | 14 | expect(success('foo')).toEqual({ 15 | type: 'foo/SUCCESS', 16 | payload: 'foo', 17 | }); 18 | 19 | const err = new Error('foo'); 20 | expect(fail(err)).toEqual({ 21 | type: 'foo/ERROR', 22 | payload: err, 23 | error: true, 24 | }); 25 | }); 26 | 27 | test('creates a triad of action creators with custom payloads', () => { 28 | const [start, success, fail] = createAsyncActions( 29 | 'bar', 30 | (str: string) => str, 31 | (length: number) => length, 32 | ); 33 | expect(start.type).toBe('bar'); 34 | expect(success.type).toBe('bar/SUCCESS'); 35 | expect(fail.type).toBe('bar/ERROR'); 36 | 37 | expect(start('bar')).toEqual({ 38 | type: 'bar', 39 | payload: 'bar', 40 | }); 41 | 42 | expect(success(3)).toEqual({ 43 | type: 'bar/SUCCESS', 44 | payload: 3, 45 | }); 46 | 47 | const err = new Error('foo'); 48 | expect(fail(err)).toEqual({ 49 | type: 'bar/ERROR', 50 | payload: err, 51 | error: true, 52 | }); 53 | }); 54 | 55 | test('allows for mixed void and any', () => { 56 | const [start, success, fail] = createAsyncActions( 57 | 'baz', 58 | () => {}, 59 | (users: { name: string }[]) => users, 60 | ); 61 | 62 | expect(start()).toEqual({ 63 | type: 'baz', 64 | }); 65 | expect(success([{ name: 'knpwrs' }])).toEqual({ 66 | type: 'baz/SUCCESS', 67 | payload: [{ name: 'knpwrs' }], 68 | }); 69 | const err = new Error('baz'); 70 | expect(fail(err)).toEqual({ 71 | type: 'baz/ERROR', 72 | payload: err, 73 | error: true, 74 | }); 75 | }); 76 | -------------------------------------------------------------------------------- /src/create-async-actions.ts: -------------------------------------------------------------------------------- 1 | import createAction, { PayloadCreator, TsActionCreator, identity } from './create-action'; 2 | 3 | export default < 4 | PStart, 5 | AStart extends any[] = [PStart], 6 | PSuc = PStart, 7 | ASuc extends any[] = AStart, 8 | PErr = Error, 9 | AErr extends any[] = [PErr] 10 | >( 11 | name: string, 12 | startPc: PayloadCreator = identity, 13 | sucPc: PayloadCreator = identity, 14 | errPc: PayloadCreator = identity, 15 | ): [ 16 | TsActionCreator, 17 | TsActionCreator, 18 | TsActionCreator, 19 | ] => [ 20 | createAction(name, startPc), 21 | createAction(`${name}/SUCCESS`, sucPc), 22 | createAction(`${name}/ERROR`, errPc), 23 | ]; 24 | -------------------------------------------------------------------------------- /src/example.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-param-reassign, no-console */ 2 | import * as assert from 'assert'; 3 | import { createStore, Store } from 'redux'; 4 | import { createAction, handleAction, reduceReducers } from '.'; 5 | 6 | // Actions 7 | 8 | const increment = createAction('increment'); 9 | const decrement = createAction('decrement'); 10 | const add = createAction('add'); 11 | const override = createAction('override'); 12 | 13 | // Reducer 14 | 15 | interface State { 16 | readonly counter: number; 17 | } 18 | 19 | const initialState: State = { 20 | counter: 0, 21 | }; 22 | 23 | const reducer = reduceReducers([ 24 | handleAction(increment, (state) => { 25 | state.counter += 1; 26 | }), 27 | handleAction(decrement, (state) => { 28 | state.counter -= 1; 29 | }), 30 | handleAction(add, (state, { payload }) => { 31 | state.counter += payload; 32 | }), 33 | handleAction(override, (_, { payload }) => ({ 34 | counter: payload, 35 | })), 36 | ], initialState); 37 | 38 | // Store 39 | 40 | const store: Store = createStore(reducer); 41 | store.subscribe(() => console.log('New state!', store.getState())); 42 | 43 | // Go to town! 44 | 45 | store.dispatch(increment()); 46 | store.dispatch(increment()); 47 | store.dispatch(increment()); 48 | store.dispatch(decrement()); 49 | store.dispatch(add(10)); 50 | console.log('Final count!', store.getState().counter); 51 | 52 | assert(store.getState().counter === 12); 53 | -------------------------------------------------------------------------------- /src/handle-action.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-param-reassign */ 2 | 3 | import handleAction from './handle-action'; 4 | import createAction from './create-action'; 5 | 6 | test('handles specific action', () => { 7 | const ac1 = createAction('foo1'); 8 | const ac2 = createAction('bar1'); 9 | const state: { readonly counter: number } = { counter: 0 }; 10 | const re = handleAction(ac1, (draft) => { 11 | draft.counter += 1; 12 | }, state); 13 | const newState1 = re(state, ac1()); 14 | expect(newState1).toEqual({ counter: 1 }); 15 | expect(newState1).not.toBe(state); 16 | const newState2 = re(state, ac2()); 17 | expect(newState2).toEqual({ counter: 0 }); 18 | expect(newState2).toBe(state); 19 | }); 20 | 21 | test('handles specific action with payload', () => { 22 | const ac1 = createAction<{ num: number }>('foo2'); 23 | const ac2 = createAction<{ num: number }>('bar2'); 24 | const state: { readonly counter: number } = { counter: 0 }; 25 | const re = handleAction(ac1, (draft, { payload }) => { 26 | draft.counter += payload.num; 27 | }, state); 28 | const newState1 = re(state, ac1({ num: 10 })); 29 | expect(newState1).toEqual({ counter: 10 }); 30 | expect(newState1).not.toBe(state); 31 | const newState2 = re(state, ac2({ num: 10 })); 32 | expect(newState2).toEqual({ counter: 0 }); 33 | expect(newState2).toBe(state); 34 | }); 35 | 36 | test('handles specific action with payload by returning value directly', () => { 37 | const ac1 = createAction<{ num: number }>('foo3'); 38 | const state: { readonly counter: number } = { counter: 0 }; 39 | const re = handleAction(ac1, (draft, { payload }) => ({ 40 | counter: draft.counter + payload.num, 41 | }), state); 42 | const newState1 = re(state, ac1({ num: 7 })); 43 | expect(newState1).toEqual({ counter: 7 }); 44 | expect(newState1).not.toBe(state); 45 | }); 46 | 47 | test('handles specific action with payload and ignores directly returned value if draft is mutated', () => { 48 | const ac1 = createAction<{ num: number }>('foo4'); 49 | const state: { readonly counter: number } = { counter: 0 }; 50 | const re = handleAction(ac1, (draft, { payload }) => { 51 | draft.counter += payload.num; 52 | return 'unintended return value'; 53 | }, state); 54 | const newState1 = re(state, ac1({ num: 10 })); 55 | expect(newState1).toEqual({ counter: 10 }); 56 | expect(newState1).not.toBe(state); 57 | }); 58 | 59 | test('handles specific action and uses previous state if directly return value is undefined', () => { 60 | const ac1 = createAction('foo5'); 61 | const state: { readonly baz: number } = { baz: 0 }; 62 | const re = handleAction(ac1, () => undefined, state); 63 | const newState1 = re(state, ac1()); 64 | expect(newState1).toEqual({ baz: 0 }); 65 | expect(newState1).toBe(state); 66 | }); 67 | 68 | test('supports default state', () => { 69 | const ac1 = createAction('foo6'); 70 | const state: { readonly baz: number } = { baz: 0 }; 71 | const re = handleAction(ac1, () => undefined, state); 72 | const newState1 = re(undefined, { type: '@@redux/INIT' }); 73 | expect(newState1).toEqual({ baz: 0 }); 74 | expect(newState1).toBe(state); 75 | }); 76 | 77 | test('supports primitive state', () => { 78 | const ac1 = createAction('toUpperCase'); 79 | const state = 'foobarbaz'; 80 | const re = handleAction(ac1, s => s.toUpperCase(), state); 81 | const newState1 = re(state, ac1()); 82 | expect(newState1).toBe('FOOBARBAZ'); 83 | }); 84 | -------------------------------------------------------------------------------- /src/handle-action.ts: -------------------------------------------------------------------------------- 1 | import { Reducer } from 'redux'; 2 | import { 3 | Draft, 4 | createDraft, 5 | finishDraft, 6 | isDraftable, 7 | } from 'immer'; 8 | import { 9 | // eslint-disable-next-line no-unused-vars 10 | TsActionCreator, 11 | // eslint-disable-next-line no-unused-vars 12 | TsAction, 13 | } from './create-action'; 14 | 15 | export { Draft } from 'immer'; 16 | 17 | export type TsReducer> = (s: Draft, p: A) => void; 18 | 19 | export default function handleAction = any>( 20 | ac: AC, 21 | re: TsReducer>, 22 | s?: S, 23 | ): Reducer> { 24 | return (state: S | undefined, action: ReturnType) => { 25 | if (action.type === ac.type && state) { 26 | if (isDraftable(state)) { 27 | const draft = createDraft(state); 28 | const reResult = re(draft, action); 29 | const finishedDraft = finishDraft(draft); 30 | 31 | if (finishedDraft === state && reResult !== undefined) { 32 | return reResult; 33 | } 34 | 35 | return finishedDraft; 36 | } 37 | // Support primitive-returning reducers 38 | return re(state as Draft, action); 39 | } 40 | return (state || s) as any; 41 | }; 42 | } 43 | -------------------------------------------------------------------------------- /src/index.test.ts: -------------------------------------------------------------------------------- 1 | import createAction from './create-action'; 2 | import createAsyncActions from './create-async-actions'; 3 | import handleAction from './handle-action'; 4 | import reduceReducers from './reduce-reducers'; 5 | import * as mod from '.'; 6 | 7 | test('module exports', () => { 8 | expect(mod).toEqual({ 9 | createAction, 10 | createAsyncActions, 11 | handleAction, 12 | reduceReducers, 13 | }); 14 | }); 15 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { 2 | default as createAction, 3 | PayloadCreator, 4 | TsAction, 5 | TsActionCreator, 6 | } from './create-action'; 7 | export { default as createAsyncActions } from './create-async-actions'; 8 | export { 9 | default as handleAction, 10 | Draft, 11 | TsReducer, 12 | } from './handle-action'; 13 | export { default as reduceReducers } from './reduce-reducers'; 14 | -------------------------------------------------------------------------------- /src/reduce-reducers.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-param-reassign */ 2 | 3 | import createAction from './create-action'; 4 | import handleAction, { Draft } from './handle-action'; 5 | import reduceReducers from './reduce-reducers'; 6 | 7 | test('all together (type handle)', () => { 8 | const inc = createAction('inc1'); 9 | const dec = createAction('dec1'); 10 | const add = createAction('add1'); 11 | const state: { readonly counter: number } = { counter: 0 }; 12 | const re = reduceReducers([ 13 | handleAction(inc, (draft) => { 14 | draft.counter += 1; 15 | }), 16 | handleAction(dec, (draft) => { 17 | draft.counter -= 1; 18 | }), 19 | handleAction(add, (draft, { payload }) => { 20 | draft.counter += payload; 21 | }), 22 | ], state); 23 | const actions = [inc(), dec(), dec(), inc(), inc(), add(5)]; 24 | expect(actions.reduce(re, undefined)).toEqual({ counter: 6 }); 25 | }); 26 | 27 | test('all together (type reducers)', () => { 28 | const inc = createAction('inc2'); 29 | const dec = createAction('dec2'); 30 | const add = createAction('add2'); 31 | const state: { readonly counter: number } = { counter: 0 }; 32 | const re = reduceReducers([ 33 | handleAction(inc, (draft: Draft) => { 34 | draft.counter += 1; 35 | }), 36 | handleAction(dec, (draft: Draft) => { 37 | draft.counter -= 1; 38 | }), 39 | handleAction(add, (draft: Draft, { payload }) => { 40 | draft.counter += payload; 41 | }), 42 | ], state); 43 | const actions = [inc(), dec(), dec(), inc(), inc(), add(5)]; 44 | expect(actions.reduce(re, undefined)).toEqual({ counter: 6 }); 45 | }); 46 | 47 | 48 | test('all together (type reduceReducers)', () => { 49 | const inc = createAction('inc3'); 50 | const dec = createAction('dec3'); 51 | const add = createAction('add3'); 52 | const state: { readonly counter: number } = { counter: 0 }; 53 | const re = reduceReducers([ 54 | handleAction(inc, (draft) => { 55 | draft.counter += 1; 56 | }), 57 | handleAction(dec, (draft) => { 58 | draft.counter -= 1; 59 | }), 60 | handleAction(add, (draft, { payload }) => { 61 | draft.counter += payload; 62 | }), 63 | ], state); 64 | const actions = [inc(), dec(), dec(), inc(), inc(), add(5)]; 65 | expect(actions.reduce(re, undefined)).toEqual({ counter: 6 }); 66 | }); 67 | -------------------------------------------------------------------------------- /src/reduce-reducers.ts: -------------------------------------------------------------------------------- 1 | import { Reducer } from 'redux'; 2 | 3 | export default (res: Reducer[], initialState?: S): Reducer => ( 4 | (state = initialState as S, action) => ( 5 | res.reduce( 6 | (curr, re) => re(curr, action), 7 | state, 8 | ) 9 | ) 10 | ); 11 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2015", 4 | "lib": ["es2015"], 5 | "declaration": true, 6 | "sourceMap": true, 7 | "rootDir": "./src", 8 | "strict": true, 9 | "noImplicitAny": true, 10 | "strictNullChecks": true, 11 | "strictFunctionTypes": true, 12 | "noImplicitThis": true, 13 | "alwaysStrict": true, 14 | "noUnusedLocals": true, 15 | "noUnusedParameters": true, 16 | "noImplicitReturns": true, 17 | "noFallthroughCasesInSwitch": true, 18 | "moduleResolution": "node" 19 | }, 20 | "exclude": ["./dist"] 21 | } 22 | --------------------------------------------------------------------------------