├── .gitignore ├── .prettierrc ├── .travis.yml ├── .vscode └── settings.json ├── CHANGELOG.md ├── LICENSE ├── README.md ├── __dtslint__ ├── generic-reducers.dtslint.ts └── immer-reducer.dtslint.ts ├── __tests__ ├── immer-reducer.test.tsx └── use-reducer-integration.test.tsx ├── jest.config.js ├── package.json ├── src └── immer-reducer.ts ├── tsconfig.build.json ├── tsconfig.dtslint.json ├── tsconfig.json └── tslint.json /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /package-lock.json 3 | /lib -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "bracketSpacing": false, 3 | "trailingComma": "all", 4 | "tabWidth": 4 5 | } 6 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - 10 4 | - 8 5 | script: npm test 6 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules/typescript/lib" 3 | } -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | See 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Esa-Matti Suuronen 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 all 13 | 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 THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # immer-reducer 2 | 3 | Type-safe and terse reducers with Typescript for React Hooks and Redux using [Immer](https://immerjs.github.io/immer/)! 4 | 5 | ## 📦 Install 6 | 7 | npm install immer-reducer 8 | 9 | You can also install [eslint-plugin-immer-reducer](https://github.com/skoshy/eslint-plugin-immer-reducer) to help you avoid errors when writing your reducer. 10 | 11 | ## 💪 Motivation 12 | 13 | Turn this 💩 💩 💩 14 | 15 | ```ts 16 | interface SetFirstNameAction { 17 | type: "SET_FIRST_NAME"; 18 | firstName: string; 19 | } 20 | 21 | interface SetLastNameAction { 22 | type: "SET_LAST_NAME"; 23 | lastName: string; 24 | } 25 | 26 | type Action = SetFirstNameAction | SetLastNameAction; 27 | 28 | function reducer(action: Action, state: State): State { 29 | switch (action.type) { 30 | case "SET_FIRST_NAME": 31 | return { 32 | ...state, 33 | user: { 34 | ...state.user, 35 | firstName: action.firstName, 36 | }, 37 | }; 38 | case "SET_LAST_NAME": 39 | return { 40 | ...state, 41 | user: { 42 | ...state.user, 43 | lastName: action.lastName, 44 | }, 45 | }; 46 | default: 47 | return state; 48 | } 49 | } 50 | ``` 51 | 52 | ✨✨ Into this! ✨✨ 53 | 54 | ```ts 55 | import {ImmerReducer} from "immer-reducer"; 56 | 57 | class MyImmerReducer extends ImmerReducer { 58 | setFirstName(firstName: string) { 59 | this.draftState.user.firstName = firstName; 60 | } 61 | 62 | setLastName(lastName: string) { 63 | this.draftState.user.lastName = lastName; 64 | } 65 | } 66 | ``` 67 | 68 | 🔥🔥 **Without losing type-safety!** 🔥🔥 69 | 70 | Oh, and you get the action creators for free! 🤗 🎂 71 | 72 | ## 📖 Usage 73 | 74 | Generate Action Creators and the actual reducer function for Redux from the class with 75 | 76 | ```ts 77 | import {createStore} from "redux"; 78 | import {createActionCreators, createReducerFunction} from "immer-reducer"; 79 | 80 | const initialState: State = { 81 | user: { 82 | firstName: "", 83 | lastName: "", 84 | }, 85 | }; 86 | 87 | const ActionCreators = createActionCreators(MyImmerReducer); 88 | const reducerFunction = createReducerFunction(MyImmerReducer, initialState); 89 | 90 | const store = createStore(reducerFunction); 91 | ``` 92 | 93 | Dispatch some actions 94 | 95 | ```ts 96 | store.dispatch(ActionCreators.setFirstName("Charlie")); 97 | store.dispatch(ActionCreators.setLastName("Brown")); 98 | 99 | expect(store.getState().user.firstName).toEqual("Charlie"); 100 | expect(store.getState().user.lastName).toEqual("Brown"); 101 | ``` 102 | 103 | ## 🌟 Typed Action Creators! 104 | 105 | The generated `ActionCreator` object respect the types used in the class 106 | 107 | ```ts 108 | const action = ActionCreators.setFirstName("Charlie"); 109 | action.payload; // Has the type of string 110 | 111 | ActionCreators.setFirstName(1); // Type error. Needs string. 112 | ActionCreators.setWAT("Charlie"); // Type error. Unknown method 113 | ``` 114 | 115 | If the reducer class where to have a method which takes more than one argument 116 | the payload would be array of the arguments 117 | 118 | ```ts 119 | // In the Reducer class: 120 | // setName(firstName: string, lastName: string) {} 121 | const action = ActionCreators.setName("Charlie", "Brown"); 122 | action.payload; // will have value ["Charlie", "Brown"] and type [string, string] 123 | ``` 124 | 125 | The reducer function is also typed properly 126 | 127 | ```ts 128 | const reducer = createReducerFunction(MyImmerReducer); 129 | 130 | reducer(initialState, ActionCreators.setFirstName("Charlie")); // OK 131 | reducer(initialState, {type: "WAT"}); // Type error 132 | reducer({wat: "bad state"}, ActionCreators.setFirstName("Charlie")); // Type error 133 | ``` 134 | 135 | ## ⚓ React Hooks 136 | 137 | Because the `useReducer()` API in React Hooks is the same as with Redux 138 | Reducers immer-reducer can be used with as is. 139 | 140 | ```tsx 141 | const initialState = {message: ""}; 142 | 143 | class ReducerClass extends ImmerReducer { 144 | setMessage(message: string) { 145 | this.draftState.message = message; 146 | } 147 | } 148 | 149 | const ActionCreators = createActionCreators(ReducerClass); 150 | const reducerFunction = createReducerFunction(ReducerClass); 151 | 152 | function Hello() { 153 | const [state, dispatch] = React.useReducer(reducerFunction, initialState); 154 | 155 | return ( 156 | 164 | ); 165 | } 166 | ``` 167 | 168 | The returned state and dispatch functions will be typed as you would expect. 169 | 170 | ## 🤔 How 171 | 172 | Under the hood the class is deconstructed to following actions: 173 | 174 | ```js 175 | { 176 | type: "IMMER_REDUCER:MyImmerReducer#setFirstName", 177 | payload: "Charlie", 178 | } 179 | { 180 | type: "IMMER_REDUCER:MyImmerReducer#setLastName", 181 | payload: "Brown", 182 | } 183 | { 184 | type: "IMMER_REDUCER:MyImmerReducer#setName", 185 | payload: ["Charlie", "Brown"], 186 | args: true 187 | } 188 | ``` 189 | 190 | So the class and method names become the Redux Action Types and the method 191 | arguments become the action payloads. The reducer function will then match 192 | these actions against the class and calls the appropriate methods with the 193 | payload array spread to the arguments. 194 | 195 | 🚫 The format of the `action.type` string is internal to immer-reducer. If 196 | you need to detect the actions use the provided type guards. 197 | 198 | The generated reducer function executes the methods inside the `produce()` 199 | function of Immer enabling the terse mutatable style updates. 200 | 201 | ## 🔄 Integrating with the Redux ecosystem 202 | 203 | To integrate for example with the side effects libraries such as 204 | [redux-observable](https://github.com/redux-observable/redux-observable/) and 205 | [redux-saga](https://github.com/redux-saga/redux-saga), you can access the 206 | generated action type using the `type` property of the action creator 207 | function. 208 | 209 | With redux-observable 210 | 211 | ```ts 212 | // Get the action name to subscribe to 213 | const setFirstNameActionTypeName = ActionCreators.setFirstName.type; 214 | 215 | // Get the action type to have a type safe Epic 216 | type SetFirstNameAction = ReturnType; 217 | 218 | const setFirstNameEpic: Epic = action$ => 219 | action$ 220 | .ofType(setFirstNameActionTypeName) 221 | .pipe( 222 | // action.payload - recognized as string 223 | map(action => action.payload.toUpperCase()), 224 | ... 225 | ); 226 | ``` 227 | 228 | With redux-saga 229 | 230 | ```ts 231 | function* watchFirstNameChanges() { 232 | yield takeEvery(ActionCreators.setFirstName.type, doStuff); 233 | } 234 | 235 | // or use the isActionFrom() to get all actions from a specific ImmerReducer 236 | // action creators object 237 | function* watchImmerActions() { 238 | yield takeEvery( 239 | (action: Action) => isActionFrom(action, MyImmerReducer), 240 | handleImmerReducerAction, 241 | ); 242 | } 243 | 244 | function* handleImmerReducerAction(action: Actions) { 245 | // `action` is a union of action types 246 | if (isAction(action, ActionCreators.setFirstName)) { 247 | // with action of setFirstName 248 | } 249 | } 250 | ``` 251 | 252 | **Warning:** Due to how immer-reducers action generation works, adding default 253 | parameters to the methods will NOT pass it to the action payload, which can 254 | make your reducer impure and the values will not be available in middlewares. 255 | 256 | ```ts 257 | class MyImmerReducer extends ImmerReducer { 258 | addItem (id: string = uuid()) { 259 | this.draftState.ids.push([id]) 260 | } 261 | } 262 | 263 | immerActions.addItem() // generates empty payload { payload: [] } 264 | ``` 265 | 266 | As a workaround, create custom action creator wrappers that pass the default parameters instead. 267 | 268 | ```ts 269 | class MyImmerReducer extends ImmerReducer { 270 | addItem (id) { 271 | this.draftState.ids.push([id]) 272 | } 273 | } 274 | 275 | const actions = { 276 | addItem: () => immerActions.addItem(id) 277 | } 278 | ``` 279 | 280 | It is also recommended to install the ESLint plugin in the "Install" section 281 | to alert you if you accidentally encounter this issue. 282 | 283 | ## 📚 Examples 284 | 285 | Here's a more complete example with redux-saga and [redux-render-prop](https://github.com/epeli/redux-render-prop): 286 | 287 | 288 | 289 | ## 🃏 Tips and Tricks 290 | 291 | You can replace the whole `draftState` with a new state if you'd like. This could be useful if you'd like to reset back to your initial state. 292 | 293 | ```ts 294 | import {ImmerReducer} from "immer-reducer"; 295 | 296 | const initialState: State = { 297 | user: { 298 | firstName: "", 299 | lastName: "", 300 | }, 301 | }; 302 | 303 | class MyImmerReducer extends ImmerReducer { 304 | // omitting other reducer methods 305 | 306 | reset() { 307 | this.draftState = initialState; 308 | } 309 | } 310 | ``` 311 | 312 | ## 📓 Helpers 313 | 314 | The module exports following helpers 315 | 316 | ### `function isActionFrom(action, ReducerClass)` 317 | 318 | Type guard for detecting whether the given action is generated by the given 319 | reducer class. The detected type will be union of actions the class 320 | generates. 321 | 322 | Example 323 | 324 | ```ts 325 | if (isActionFrom(someAction, ActionCreators)) { 326 | // someAction now has type of 327 | // { 328 | // type: "setFirstName"; 329 | // payload: string; 330 | // } | { 331 | // type: "setLastName"; 332 | // payload: string; 333 | // }; 334 | } 335 | ``` 336 | 337 | ### `function isAction(action, actionCreator)` 338 | 339 | Type guard for detecting specific actions generated by immer-reducer. 340 | 341 | Example 342 | 343 | ```ts 344 | if (isAction(someAction, ActionCreators.setFirstName)) { 345 | someAction.payload; // Type checks to `string` 346 | } 347 | ``` 348 | 349 | ### `type Actions` 350 | 351 | Get union of the action types generated by the ImmerReducer class 352 | 353 | Example 354 | 355 | ```ts 356 | type MyActions = Actions; 357 | 358 | // Is the same as 359 | type MyActions = 360 | | { 361 | type: "setFirstName"; 362 | payload: string; 363 | } 364 | | { 365 | type: "setLastName"; 366 | payload: string; 367 | }; 368 | ``` 369 | 370 | ### `function setPrefix(prefix: string)` 371 | 372 | The default prefix in the generated action types is `IMMER_REDUCER`. Call 373 | this customize it for your app. 374 | 375 | Example 376 | 377 | ```ts 378 | setPrefix("MY_APP"); 379 | ``` 380 | 381 | ### `function composeReducers(...reducers)` 382 | 383 | Utility that reduces actions by applying them through multiple reducers. 384 | This helps in allowing you to split up your reducer logic to multiple `ImmerReducer`s 385 | if they affect the same part of your state 386 | 387 | Example 388 | 389 | ```ts 390 | class MyNameReducer extends ImmerReducer { 391 | setFirstName(firstName: string) { 392 | this.draftState.firstName = firstName; 393 | } 394 | 395 | setLastName(lastName: string) { 396 | this.draftState.lastName = lastName; 397 | } 398 | } 399 | 400 | class MyAgeReducer extends ImmerReducer { 401 | setAge(age: number) { 402 | this.draftState.age = 8; 403 | } 404 | } 405 | 406 | export const reducer = composeReducers( 407 | createReducerFunction(MyNameReducer, initialState), 408 | createReducerFunction(MyAgeReducer, initialState) 409 | ) 410 | ``` 411 | -------------------------------------------------------------------------------- /__dtslint__/generic-reducers.dtslint.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ImmerReducer, 3 | createReducerFunction, 4 | createActionCreators, 5 | ImmerReducerState, 6 | } from "../src/immer-reducer"; 7 | 8 | interface AssignFail { 9 | ___: "it should not be possible to assign to me"; 10 | } 11 | 12 | interface State { 13 | foo: { 14 | fooField1: string; 15 | fooField2: number; 16 | }; 17 | 18 | bar: { 19 | barField1: number[]; 20 | barField2: RegExp; 21 | }; 22 | } 23 | 24 | const initialState: State = { 25 | foo: { 26 | fooField1: "a", 27 | fooField2: 1, 28 | }, 29 | bar: { 30 | barField1: [1, 2], 31 | barField2: /re/, 32 | }, 33 | }; 34 | 35 | function createGenericReducer() { 36 | return class GenericReducer extends ImmerReducer { 37 | set(part: Partial) { 38 | Object.assign(this.draftState, part); 39 | } 40 | }; 41 | } 42 | const ReducerClassFoo = createGenericReducer(); 43 | const ReducerClassBar = createGenericReducer(); 44 | 45 | //////////////////// 46 | // Instance tests // 47 | //////////////////// 48 | 49 | const ins = new ReducerClassFoo(initialState.foo, initialState.foo); 50 | 51 | const state_test_1: State["foo"] = ins.state; 52 | const state_test_2: State["foo"] = ins.draftState; 53 | 54 | // cannot assign to wrong state (ie. was not any) 55 | // $ExpectError 56 | const state_test_3: AssignFail = ins.state; 57 | // $ExpectError 58 | const state_test_4: AssignFail = ins.draftState; 59 | 60 | ////////////////////////// 61 | // Action Creator tests // 62 | ////////////////////////// 63 | 64 | const ActionCreatorsFoo = createActionCreators(ReducerClassFoo); 65 | const ActionCreatorsBar = createActionCreators(ReducerClassBar); 66 | 67 | ActionCreatorsFoo.set({fooField1: "b"}); 68 | ActionCreatorsFoo.set({fooField2: 2}); 69 | 70 | ActionCreatorsBar.set({barField1: [8]}); 71 | ActionCreatorsBar.set({barField2: /ding/}); 72 | 73 | // Cannot set bad values 74 | // $ExpectError 75 | ActionCreatorsFoo.set({fooField1: 2}); 76 | 77 | // Cannot set unknown fields 78 | // $ExpectError 79 | ActionCreatorsFoo.set({bad: 2}); 80 | 81 | // Cannot set bar fields 82 | // $ExpectError 83 | ActionCreatorsFoo.set({barField1: [8]}); 84 | 85 | //////////////////////////// 86 | // Reducer function tests // 87 | //////////////////////////// 88 | 89 | const reducerFoo = createReducerFunction(ReducerClassFoo, initialState.foo); 90 | 91 | reducerFoo(initialState, ActionCreatorsFoo.set({fooField1: "c"})); 92 | 93 | // no bad actions allowed 94 | // $ExpectError 95 | reducerFoo(initialState, {type: "BAD_ACTION"}); 96 | 97 | // XXX bug! :( State is any here. This should fail! 98 | reducerFoo({bad: "state"}, ActionCreatorsFoo.set({fooField1: "c"})); 99 | 100 | // For some reason ImmerReducerState cannot infer state 101 | // from a generic class. Maybe this is a limitation in Typescript? 102 | 103 | type InferredState = ImmerReducerState; 104 | declare const inferredState: InferredState; 105 | 106 | // XXX! Should fail too! 107 | const anumber: AssignFail = inferredState; 108 | -------------------------------------------------------------------------------- /__dtslint__/immer-reducer.dtslint.ts: -------------------------------------------------------------------------------- 1 | import {Action, createStore, bindActionCreators} from "redux"; 2 | 3 | import { 4 | ImmerReducer, 5 | createActionCreators, 6 | createReducerFunction, 7 | isAction, 8 | Actions, 9 | isActionFrom, 10 | } from "../src/immer-reducer"; 11 | import {Dispatch} from "react"; 12 | import React from "react"; 13 | 14 | interface AssertNotAny { 15 | ___: "it should not be possible to assign to me"; 16 | } 17 | 18 | interface State { 19 | readonly foo: string; 20 | readonly bar: number; 21 | } 22 | 23 | class MyReducer extends ImmerReducer { 24 | setBoth(newFoo: string, newBar: number) { 25 | this.setBar(newBar); 26 | this.setFoo(newFoo); 27 | } 28 | 29 | setFoo(newFoo: string) { 30 | this.draftState.foo = newFoo; 31 | } 32 | 33 | setBar(newBar: number) { 34 | this.draftState.bar = newBar; 35 | } 36 | 37 | setFooStatic() { 38 | this.draftState.foo = "static"; 39 | } 40 | } 41 | 42 | //////////////////// 43 | // Test action types 44 | //////////////////// 45 | 46 | const ActionCreators = createActionCreators(MyReducer); 47 | 48 | // Action creator return Action Object 49 | const action: { 50 | type: "setBar"; 51 | payload: number; 52 | } = ActionCreators.setBar(3); 53 | 54 | // the action creator does no return any 55 | // $ExpectError 56 | const is_not_any: AssertNotAny = ActionCreators.setBar(3); 57 | 58 | // actions without payload 59 | const staticAction = ActionCreators.setFooStatic(); 60 | const staticPayload: [] = staticAction.payload; 61 | 62 | // Actions with multiple items in the payload 63 | const bothAction = ActionCreators.setBoth("foo", 1); 64 | 65 | const bothPayload: [string, number] = bothAction.payload; 66 | 67 | // Only function properties are picked 68 | // $ExpectError 69 | ActionCreators.draftState; 70 | // $ExpectError 71 | ActionCreators.state; 72 | 73 | // Do not allow bad argument types 74 | // $ExpectError 75 | ActionCreators.setBar("sdf"); 76 | 77 | // Do not allow bad method names 78 | // $ExpectError 79 | ActionCreators.setBad(3); 80 | 81 | ////////////////////// 82 | // Test reducer types 83 | ////////////////////// 84 | 85 | class BadReducer { 86 | dong() {} 87 | } 88 | 89 | // Cannot create action creators from random classes 90 | // $ExpectError 91 | createActionCreators(BadReducer); 92 | 93 | const reducer = createReducerFunction(MyReducer); 94 | 95 | // can create with proper initial state 96 | createReducerFunction(MyReducer, {foo: "", bar: 0}); 97 | 98 | // Bad state argument is not allowed 99 | // $ExpectError 100 | createReducerFunction(MyReducer, {bad: "state"}); 101 | 102 | const newState: State = reducer( 103 | {foo: "sdf", bar: 2}, 104 | { 105 | type: "setBar", 106 | payload: 3, 107 | }, 108 | ); 109 | 110 | // reducer does not return any 111 | // $ExpectError 112 | const no_any_state: AssertNotAny = reducer( 113 | {foo: "f", bar: 2}, 114 | { 115 | type: "setBar", 116 | payload: 3, 117 | }, 118 | ); 119 | 120 | // bad state for the reducer 121 | reducer( 122 | // $ExpectError 123 | {foo: "sdf", bar: "should be number"}, 124 | { 125 | type: "setBar", 126 | payload: 3, 127 | }, 128 | ); 129 | 130 | // Bad action object 131 | // $ExpectError 132 | reducer({foo: "sdf", bar: 2}, {}); 133 | 134 | // Bad payload type 135 | reducer( 136 | {foo: "sdf", bar: 2}, 137 | // $ExpectError 138 | { 139 | type: "setBar", 140 | payload: "should be number here", 141 | }, 142 | ); 143 | 144 | // Bad action type 145 | reducer( 146 | {foo: "sdf", bar: 2}, 147 | { 148 | // $ExpectError 149 | type: "bad", 150 | payload: 3, 151 | }, 152 | ); 153 | 154 | reducer({foo: "sdf", bar: 2}, ActionCreators.setBar(3)); 155 | 156 | class OtherReducer extends ImmerReducer { 157 | setDing(dong: string) { 158 | this.draftState.foo = dong; 159 | } 160 | } 161 | 162 | const OtherActionCreators = createActionCreators(OtherReducer); 163 | 164 | // Mixed reducer and action creators from different ImmerReducer classes 165 | // $ExpectError 166 | reducer({foo: "sdf", bar: 2}, OtherActionCreators.setDing("sdf")); 167 | 168 | // Action creator provides action type 169 | const actionType: "setBar" = ActionCreators.setBar.type; 170 | 171 | // $ExpectError 172 | const actionType_not_any: AssertNotAny = ActionCreators.setBar.type; 173 | 174 | ////////////////////// 175 | // Test isAction types 176 | ////////////////////// 177 | 178 | declare const unknownAction: {type: string}; 179 | 180 | if (isAction(unknownAction, ActionCreators.setBar)) { 181 | // $ExpectError 182 | const actione_not_any: AssertNotAny = unknownAction; 183 | 184 | const knownAction: { 185 | type: "setBar"; 186 | payload: number; 187 | } = unknownAction; 188 | 189 | // $ExpectError 190 | const nope: string = unknownAction.payload; 191 | } 192 | 193 | ///////////////////////////// 194 | // Test Actions<> type helper 195 | ///////////////////////////// 196 | 197 | class Reducer1 extends ImmerReducer { 198 | setFoo(newFoo: string) { 199 | this.draftState.foo = newFoo; 200 | } 201 | 202 | setBar(newBar: number) { 203 | this.draftState.bar = newBar; 204 | } 205 | } 206 | 207 | type MyActions = Actions; 208 | 209 | declare const someActions: MyActions; 210 | 211 | // $ExpectError 212 | const someActionsNotAny: AssertNotAny = someActions; 213 | 214 | const someActionsTest: 215 | | { 216 | type: "setFoo"; 217 | payload: string; 218 | } 219 | | { 220 | type: "setBar"; 221 | payload: number; 222 | } = someActions; 223 | 224 | type MyReducerActions = Actions; 225 | declare const myReducerActions: MyReducerActions; 226 | 227 | // $ExpectError 228 | const actions_not_any: AssertNotAny = myReducerActions; 229 | 230 | const actions_manual: 231 | | { 232 | type: "setFoo"; 233 | payload: string; 234 | } 235 | | { 236 | type: "setBar"; 237 | payload: number; 238 | } = myReducerActions; 239 | 240 | ////////////////////////// 241 | // Test isActionFrom types 242 | ////////////////////////// 243 | 244 | declare const someAction: Action; 245 | 246 | const ActionCreators1 = createActionCreators(Reducer1); 247 | 248 | if (isActionFrom(someAction, Reducer1)) { 249 | // $ExpectError 250 | const notany: AssertNotAny = someAction; 251 | 252 | const actions_manual: 253 | | { 254 | type: "setFoo"; 255 | payload: string; 256 | } 257 | | { 258 | type: "setBar"; 259 | payload: number; 260 | } = someAction; 261 | } 262 | 263 | test("Can work with bindActionCreators", () => { 264 | const initialState = {foo: ""}; 265 | const store = createStore(s => initialState); 266 | 267 | class Reducer extends ImmerReducer { 268 | setFoo(foo: string) {} 269 | } 270 | 271 | const ActionCreators = createActionCreators(Reducer); 272 | 273 | const boundActionCreators = bindActionCreators( 274 | ActionCreators, 275 | store.dispatch, 276 | ); 277 | }); 278 | 279 | test("can use with React.useReducer()", () => { 280 | const initialState = {foo: ""}; 281 | 282 | class Reducer extends ImmerReducer { 283 | setFoo(foo: string) {} 284 | } 285 | 286 | const ActionCreators = createActionCreators(Reducer); 287 | const reducerFuntion = createReducerFunction(Reducer); 288 | 289 | function Component1() { 290 | const [state, dispatch] = React.useReducer( 291 | reducerFuntion, 292 | initialState, 293 | ); 294 | 295 | const callback = () => { 296 | dispatch(ActionCreators.setFoo("test")); 297 | 298 | // $ExpectError 299 | dispatch("bad"); 300 | 301 | const foo: string = state.foo; 302 | 303 | // $ExpectError 304 | const bar: AssertNotAny = state.foo; 305 | }; 306 | 307 | return null; 308 | } 309 | }); 310 | -------------------------------------------------------------------------------- /__tests__/immer-reducer.test.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | ImmerReducer, 3 | createReducerFunction, 4 | createActionCreators, 5 | composeReducers, 6 | setPrefix, 7 | _clearKnownClasses, 8 | isAction, 9 | isActionFrom, 10 | } from "../src/immer-reducer"; 11 | 12 | import {createStore, combineReducers, Action} from "redux"; 13 | 14 | beforeEach(_clearKnownClasses); 15 | 16 | afterEach(() => { 17 | setPrefix("IMMER_REDUCER"); 18 | }); 19 | 20 | test("can detect inherited actions", () => { 21 | class Parent extends ImmerReducer { 22 | setFoo(foo: string) {} 23 | } 24 | 25 | class Child extends Parent { 26 | setFoo2(foo: string) {} 27 | } 28 | 29 | const actions = createActionCreators(Child); 30 | expect(actions.setFoo).toBeTruthy(); 31 | expect(actions.setFoo2).toBeTruthy(); 32 | }); 33 | 34 | test("can create reducers", () => { 35 | const initialState = {foo: "bar"}; 36 | 37 | class TestReducer extends ImmerReducer { 38 | setFoo(foo: string) { 39 | this.draftState.foo = foo; 40 | } 41 | } 42 | 43 | const reducer = createReducerFunction(TestReducer); 44 | const store = createStore(reducer, initialState); 45 | 46 | expect(store.getState()).toEqual({foo: "bar"}); 47 | }); 48 | 49 | test("the reducer can return the initial state", () => { 50 | const initialState = {foo: "bar"}; 51 | 52 | class TestReducer extends ImmerReducer { 53 | setFoo(foo: string) { 54 | this.draftState.foo = foo; 55 | } 56 | } 57 | 58 | const reducer = createReducerFunction(TestReducer, initialState); 59 | const store = createStore(reducer); 60 | 61 | expect(store.getState()).toEqual({foo: "bar"}); 62 | }); 63 | 64 | test("can dispatch actions", () => { 65 | const initialState = {foo: "bar"}; 66 | 67 | class TestReducer extends ImmerReducer { 68 | noop() {} 69 | } 70 | 71 | const ActionCreators = createActionCreators(TestReducer); 72 | const reducer = createReducerFunction(TestReducer, initialState); 73 | const store = createStore(reducer); 74 | 75 | store.dispatch(ActionCreators.noop()); 76 | 77 | expect(store.getState()).toEqual({foo: "bar"}); 78 | }); 79 | 80 | test("can update state", () => { 81 | const initialState = {foo: "bar"}; 82 | 83 | class TestReducer extends ImmerReducer { 84 | setFoo(foo: string) { 85 | this.draftState.foo = foo; 86 | } 87 | } 88 | 89 | const ActionCreators = createActionCreators(TestReducer); 90 | const reducer = createReducerFunction(TestReducer, initialState); 91 | const store = createStore(reducer); 92 | 93 | store.dispatch(ActionCreators.setFoo("next")); 94 | 95 | expect(store.getState()).toEqual({foo: "next"}); 96 | }); 97 | 98 | test("can update state using multiple methods", () => { 99 | const initialState = {foo: "bar", bar: 1}; 100 | 101 | class TestReducer extends ImmerReducer { 102 | setFoo(foo: string) { 103 | this.draftState.foo = foo; 104 | } 105 | 106 | setBar(bar: number) { 107 | this.draftState.bar = bar; 108 | } 109 | 110 | setBoth(foo: string, bar: number) { 111 | this.setFoo(foo); 112 | this.setBar(bar); 113 | } 114 | } 115 | 116 | const ActionCreators = createActionCreators(TestReducer); 117 | const reducer = createReducerFunction(TestReducer, initialState); 118 | const store = createStore(reducer); 119 | 120 | store.dispatch(ActionCreators.setBoth("next", 2)); 121 | 122 | expect(store.getState()).toEqual({foo: "next", bar: 2}); 123 | }); 124 | 125 | test("the actual action type name is prefixed", () => { 126 | const initialState = {foo: "bar"}; 127 | 128 | class TestReducer extends ImmerReducer { 129 | setFoo(foo: string) { 130 | this.draftState.foo = foo; 131 | } 132 | } 133 | 134 | const ActionCreators = createActionCreators(TestReducer); 135 | 136 | const reducer = createReducerFunction(TestReducer, initialState); 137 | const reducerSpy: typeof reducer = jest.fn(reducer); 138 | 139 | const store = createStore(reducerSpy); 140 | 141 | store.dispatch(ActionCreators.setFoo("next")); 142 | 143 | expect(reducerSpy).toHaveBeenLastCalledWith( 144 | {foo: "bar"}, 145 | { 146 | payload: "next", 147 | type: "IMMER_REDUCER:TestReducer#setFoo", 148 | }, 149 | ); 150 | }); 151 | 152 | test("can add helpers to the class", () => { 153 | const initialState = {foo: 1, bar: 1}; 154 | 155 | class Helper { 156 | state: typeof initialState; 157 | 158 | constructor(state: typeof initialState) { 159 | this.state = state; 160 | } 161 | 162 | getCombined() { 163 | return this.state.foo + this.state.bar; 164 | } 165 | } 166 | 167 | class TestReducer extends ImmerReducer { 168 | helper = new Helper(this.state); 169 | 170 | combineToBar() { 171 | this.draftState.bar = this.helper.getCombined(); 172 | } 173 | } 174 | 175 | const ActionCreators = createActionCreators(TestReducer); 176 | const reducer = createReducerFunction(TestReducer, initialState); 177 | const store = createStore(reducer); 178 | 179 | store.dispatch(ActionCreators.combineToBar()); 180 | 181 | expect(store.getState()).toEqual({foo: 1, bar: 2}); 182 | }); 183 | 184 | test("can use combineReducers", () => { 185 | interface State1 { 186 | foo: number; 187 | } 188 | 189 | interface State2 { 190 | bar: string; 191 | } 192 | 193 | class TestReducer1 extends ImmerReducer { 194 | setFoo(foo: number) { 195 | this.draftState.foo = foo; 196 | } 197 | } 198 | 199 | class TestReducer2 extends ImmerReducer { 200 | setBar(bar: string) { 201 | this.draftState.bar = bar; 202 | } 203 | } 204 | 205 | const ActionCreators1 = createActionCreators(TestReducer1); 206 | const ActionCreators2 = createActionCreators(TestReducer2); 207 | 208 | const slice1 = createReducerFunction(TestReducer1, {foo: 0}); 209 | const slice2 = createReducerFunction(TestReducer2, {bar: ""}); 210 | 211 | const combined = combineReducers({slice1, slice2}); 212 | 213 | const store = createStore(combined); 214 | 215 | store.dispatch(ActionCreators1.setFoo(1)); 216 | store.dispatch(ActionCreators2.setBar("barval")); 217 | 218 | const state: { 219 | slice1: State1; 220 | slice2: State2; 221 | } = store.getState(); 222 | 223 | expect(state).toEqual({slice1: {foo: 1}, slice2: {bar: "barval"}}); 224 | }); 225 | 226 | test("cannot collide reducers", () => { 227 | const initialState = {foo: "bar"}; 228 | 229 | class TestReducer1 extends ImmerReducer { 230 | setFoo() { 231 | this.draftState.foo = "1"; 232 | } 233 | } 234 | 235 | class TestReducer2 extends ImmerReducer { 236 | setFoo() { 237 | this.draftState.foo = "2"; 238 | } 239 | } 240 | 241 | const reducer = composeReducers( 242 | createReducerFunction(TestReducer1), 243 | createReducerFunction(TestReducer2), 244 | ); 245 | 246 | const store = createStore(reducer, initialState); 247 | 248 | const ActionCreators1 = createActionCreators(TestReducer1); 249 | const ActionCreators2 = createActionCreators(TestReducer2); 250 | 251 | store.dispatch(ActionCreators1.setFoo()); 252 | expect(store.getState()).toEqual({foo: "1"}); 253 | 254 | store.dispatch(ActionCreators2.setFoo()); 255 | expect(store.getState()).toEqual({foo: "2"}); 256 | }); 257 | 258 | test("dynamically generated reducers do not collide", () => { 259 | const initialState = { 260 | foo: "", 261 | }; 262 | 263 | function createGenericReducer( 264 | value: string, 265 | ) { 266 | return class GenericReducer extends ImmerReducer { 267 | set() { 268 | Object.assign(this.draftState, {foo: value}); 269 | } 270 | }; 271 | } 272 | const ReducerClass1 = createGenericReducer("1"); 273 | const ReducerClass2 = createGenericReducer("2"); 274 | 275 | const reducer1 = createReducerFunction(ReducerClass1, initialState); 276 | const reducer2 = createReducerFunction(ReducerClass2, initialState); 277 | 278 | const reducer = composeReducers(reducer1, reducer2); 279 | 280 | const ActionCreators1 = createActionCreators(ReducerClass1); 281 | const ActionCreators2 = createActionCreators(ReducerClass2); 282 | 283 | const store = createStore(reducer); 284 | 285 | store.dispatch(ActionCreators1.set()); 286 | expect(store.getState().foo).toEqual("1"); 287 | 288 | store.dispatch(ActionCreators2.set()); 289 | expect(store.getState().foo).toEqual("2"); 290 | }); 291 | 292 | test("can create dynamic reducers after creating actions", () => { 293 | const initialState = { 294 | foo: "", 295 | }; 296 | 297 | function createGenericReducer( 298 | value: string, 299 | ) { 300 | return class GenericReducer extends ImmerReducer { 301 | set() { 302 | Object.assign(this.draftState, {foo: value}); 303 | } 304 | }; 305 | } 306 | const ReducerClass1 = createGenericReducer("1"); 307 | const ReducerClass2 = createGenericReducer("2"); 308 | 309 | const ActionCreators1 = createActionCreators(ReducerClass1); 310 | const ActionCreators2 = createActionCreators(ReducerClass2); 311 | 312 | const reducer1 = createReducerFunction(ReducerClass1, initialState); 313 | const reducer2 = createReducerFunction(ReducerClass2, initialState); 314 | 315 | const reducer = composeReducers(reducer1, reducer2); 316 | 317 | const store = createStore(reducer); 318 | 319 | store.dispatch(ActionCreators1.set()); 320 | expect(store.getState().foo).toEqual("1"); 321 | 322 | store.dispatch(ActionCreators2.set()); 323 | expect(store.getState().foo).toEqual("2"); 324 | }); 325 | 326 | test("throw error when using duplicate customNames", () => { 327 | class Reducer1 extends ImmerReducer<{foo: string}> { 328 | static customName = "dup"; 329 | set() { 330 | this.draftState.foo = "foo"; 331 | } 332 | } 333 | 334 | class Reducer2 extends ImmerReducer<{foo: string}> { 335 | static customName = "dup"; 336 | set() { 337 | this.draftState.foo = "foo"; 338 | } 339 | } 340 | 341 | createReducerFunction(Reducer1); 342 | 343 | expect(() => { 344 | createReducerFunction(Reducer2); 345 | }).toThrow(); 346 | }); 347 | 348 | test("action creators expose the actual action type name", () => { 349 | const initialState = {foo: "bar"}; 350 | 351 | class TestReducer extends ImmerReducer { 352 | setBar(foo: string) { 353 | this.draftState.foo = foo; 354 | } 355 | } 356 | 357 | const ActionCreators = createActionCreators(TestReducer); 358 | 359 | expect(ActionCreators.setBar.type).toEqual( 360 | "IMMER_REDUCER:TestReducer#setBar", 361 | ); 362 | }); 363 | 364 | test("can customize prefix of action type name what is returned by action creator.", () => { 365 | const initialState = {foo: "bar"}; 366 | 367 | class TestReducer extends ImmerReducer { 368 | setBar(foo: string) { 369 | this.draftState.foo = foo; 370 | } 371 | } 372 | 373 | setPrefix("AWESOME_LIBRARY"); 374 | const ActionCreators = createActionCreators(TestReducer); 375 | 376 | expect(ActionCreators.setBar.type).toEqual( 377 | "AWESOME_LIBRARY:TestReducer#setBar", 378 | ); 379 | 380 | const reducer = createReducerFunction(TestReducer); 381 | const store = createStore(reducer, initialState); 382 | 383 | store.dispatch(ActionCreators.setBar("ding")); 384 | 385 | expect(store.getState()).toEqual({foo: "ding"}); 386 | }); 387 | 388 | test("isActionFrom can detect actions", () => { 389 | class TestReducer extends ImmerReducer<{foo: string}> { 390 | setFoo(foo: string) { 391 | this.draftState.foo = foo; 392 | } 393 | } 394 | const ActionCreators = createActionCreators(TestReducer); 395 | 396 | const action1: Action = ActionCreators.setFoo("foo"); 397 | 398 | const action2: Action = { 399 | type: "other", 400 | }; 401 | 402 | expect(isActionFrom(action1, TestReducer)).toBe(true); 403 | expect(isActionFrom(action2, TestReducer)).toBe(false); 404 | }); 405 | 406 | test("isAction can detect actions", () => { 407 | class TestReducer extends ImmerReducer<{foo: string}> { 408 | setFoo(foo: string) { 409 | this.draftState.foo = foo; 410 | } 411 | } 412 | const ActionCreators = createActionCreators(TestReducer); 413 | 414 | const action1: Action = ActionCreators.setFoo("foo"); 415 | 416 | const action2: Action = { 417 | type: "other", 418 | }; 419 | 420 | expect(isAction(action1, ActionCreators.setFoo)).toBe(true); 421 | expect(isAction(action2, ActionCreators.setFoo)).toBe(false); 422 | }); 423 | 424 | test("single argument is the payload value", () => { 425 | class TestReducer extends ImmerReducer<{}> { 426 | singleArg(arg: string) {} 427 | } 428 | const action = createActionCreators(TestReducer).singleArg("foo"); 429 | expect(action.payload).toEqual("foo"); 430 | }); 431 | 432 | test("multiple arguments are as an array in the payload", () => { 433 | class TestReducer extends ImmerReducer<{}> { 434 | multiple(arg1: string, arg2: number) {} 435 | } 436 | const action = createActionCreators(TestReducer).multiple("foo", 2); 437 | expect(action.payload).toEqual(["foo", 2]); 438 | }); 439 | 440 | test("single argument can be an array", () => { 441 | class TestReducer extends ImmerReducer<{}> { 442 | singleArg(arg: string[]) {} 443 | } 444 | const action = createActionCreators(TestReducer).singleArg(["foo"]); 445 | expect(action.payload).toEqual(["foo"]); 446 | }); 447 | 448 | test("single array argument is dispatched correctly", () => { 449 | expect.assertions(1); 450 | 451 | class TestReducer extends ImmerReducer<{}> { 452 | arrayArg(arr: string[]) { 453 | expect(arr).toEqual(["foo", "bar"]); 454 | } 455 | } 456 | 457 | const store = createStore(createReducerFunction(TestReducer, {})); 458 | store.dispatch(createActionCreators(TestReducer).arrayArg(["foo", "bar"])); 459 | }); 460 | 461 | test("puts only defined arguments to the action object", () => { 462 | class TestReducer extends ImmerReducer<{}> { 463 | doIt() {} 464 | } 465 | 466 | // Simulate click handler type 467 | let onClick = (arg: string): any => {}; 468 | 469 | // "Pass action the event handler" 470 | onClick = createActionCreators(TestReducer).doIt; 471 | 472 | const action = onClick("nope"); 473 | 474 | expect(action.payload).toEqual([]); 475 | }); 476 | 477 | test("puts only defined arguments to the action object", () => { 478 | class TestReducer extends ImmerReducer<{}> { 479 | doIt(oneArg: string) {} 480 | } 481 | 482 | // Simulate click handler type 483 | let onClick = (first: string, second: string): any => {}; 484 | 485 | // "Pass action the event handler" 486 | onClick = createActionCreators(TestReducer).doIt; 487 | 488 | const action = onClick("yes", "nope"); 489 | 490 | expect(action.payload).toEqual("yes"); 491 | }); 492 | 493 | test("can replace the draft state with completely new state", () => { 494 | const initialState = {foo: "bar", ding: "ding"}; 495 | 496 | class TestReducer extends ImmerReducer { 497 | resetState() { 498 | this.draftState = { 499 | foo: "new", 500 | ding: "new", 501 | }; 502 | } 503 | } 504 | 505 | const ActionCreators = createActionCreators(TestReducer); 506 | 507 | const reducer = createReducerFunction(TestReducer); 508 | const store = createStore(reducer, initialState); 509 | 510 | store.dispatch(ActionCreators.resetState()); 511 | 512 | expect(store.getState()).toEqual({ 513 | foo: "new", 514 | ding: "new", 515 | }); 516 | }); 517 | -------------------------------------------------------------------------------- /__tests__/use-reducer-integration.test.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import {render, fireEvent, cleanup} from "@testing-library/react"; 3 | import { 4 | ImmerReducer, 5 | createActionCreators, 6 | createReducerFunction, 7 | } from "../src/immer-reducer"; 8 | 9 | afterEach(cleanup); 10 | 11 | test("can use with React.useReducer()", () => { 12 | const initialState = {foo: ""}; 13 | 14 | class Reducer extends ImmerReducer { 15 | setFoo(foo: string) { 16 | this.draftState.foo = foo; 17 | } 18 | } 19 | 20 | const ActionCreators = createActionCreators(Reducer); 21 | const reducerFuntion = createReducerFunction(Reducer); 22 | 23 | function Foo() { 24 | const [state, dispatch] = React.useReducer( 25 | reducerFuntion, 26 | initialState, 27 | ); 28 | 29 | return ( 30 | 38 | ); 39 | } 40 | 41 | const rtl = render(); 42 | const button = rtl.getByTestId("button"); 43 | 44 | fireEvent.click(button); 45 | 46 | expect(button.innerHTML).toBe("clicked"); 47 | }); 48 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | moduleFileExtensions: ["ts", "tsx", "js"], 3 | transform: { 4 | "^.+\\.(ts|tsx)$": "ts-jest", 5 | }, 6 | globals: { 7 | "ts-jest": { 8 | tsconfig: "tsconfig.json", 9 | }, 10 | }, 11 | testMatch: ["**/?(*.)+(spec|test).ts?(x)"], 12 | }; 13 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "immer-reducer", 3 | "version": "0.7.13", 4 | "description": "", 5 | "main": "lib/immer-reducer.js", 6 | "types": "lib/immer-reducer.d.ts", 7 | "repository": { 8 | "url": "https://github.com/epeli/immer-reducer" 9 | }, 10 | "scripts": { 11 | "test": "npm run dtslint && jest", 12 | "build": "tsc --project tsconfig.build.json && rm -rf lib && mv build/src lib && rm -rf build", 13 | "clean": "rm -rf lib build", 14 | "dtslint": "tslint --project tsconfig.dtslint.json", 15 | "prepublishOnly": "npm run test && npm run build" 16 | }, 17 | "keywords": [ 18 | "typescript", 19 | "immer" 20 | ], 21 | "author": "", 22 | "license": "MIT", 23 | "files": [ 24 | "lib" 25 | ], 26 | "devDependencies": { 27 | "@testing-library/react": "^8.0.4", 28 | "@types/jest": "^24.0.15", 29 | "@types/react": "^16.8.22", 30 | "@types/react-dom": "^16.8.4", 31 | "@types/redux": "^3.6.0", 32 | "dtslint": "^4.0.7", 33 | "jest": "^26.6.3", 34 | "prettier": "^1.18.2", 35 | "react": "^16.8.6", 36 | "react-dom": "^16.8.6", 37 | "redux": "^4.0.1", 38 | "ts-jest": "^26.5.1", 39 | "typescript": "^3.9.9" 40 | }, 41 | "dependencies": { 42 | "immer": "^1.4.0 || ^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^8.0.0 || ^9.0.0" 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/immer-reducer.ts: -------------------------------------------------------------------------------- 1 | import produce, {Draft} from "immer"; 2 | 3 | let actionTypePrefix = "IMMER_REDUCER"; 4 | 5 | /** get function arguments as tuple type */ 6 | type ArgumentsType = T extends (...args: infer V) => any ? V : never; 7 | 8 | /** 9 | * Get the first value of tuple when the tuple length is 1 otherwise return the 10 | * whole tuple 11 | */ 12 | type FirstOrAll = T extends [infer V] ? V : T; 13 | 14 | /** Get union of function property names */ 15 | type FunctionPropertyNames = { 16 | [K in keyof T]: T[K] extends Function ? K : never; 17 | }[keyof T]; 18 | 19 | type MethodObject = {[key: string]: () => any}; 20 | 21 | /** Pick only methods from object */ 22 | type Methods = Pick>; 23 | 24 | /** flatten functions in an object to their return values */ 25 | type FlattenToReturnTypes = { 26 | [K in keyof T]: ReturnType; 27 | }; 28 | 29 | /** get union of object value types */ 30 | type ObjectValueTypes = T[keyof T]; 31 | 32 | /** get union of object method return types */ 33 | type ReturnTypeUnion = ObjectValueTypes< 34 | FlattenToReturnTypes 35 | >; 36 | 37 | /** 38 | * Get union of actions types from a ImmerReducer class 39 | */ 40 | export type Actions = ReturnTypeUnion< 41 | ActionCreators 42 | >; 43 | 44 | /** type constraint for the ImmerReducer class */ 45 | export interface ImmerReducerClass { 46 | customName?: string; 47 | new (...args: any[]): ImmerReducer; 48 | } 49 | 50 | /** get state type from a ImmerReducer subclass */ 51 | export type ImmerReducerState = T extends { 52 | prototype: { 53 | state: infer V; 54 | }; 55 | } 56 | ? V 57 | : never; 58 | 59 | /** generate reducer function type from the ImmerReducer class */ 60 | export interface ImmerReducerFunction { 61 | ( 62 | state: ImmerReducerState | undefined, 63 | action: ReturnTypeUnion>, 64 | ): ImmerReducerState; 65 | } 66 | 67 | /** ActionCreator function interface with actual action type name */ 68 | interface ImmerActionCreator { 69 | readonly type: ActionTypeType; 70 | 71 | (...args: Payload): { 72 | type: ActionTypeType; 73 | payload: FirstOrAll; 74 | }; 75 | } 76 | 77 | /** generate ActionCreators types from the ImmerReducer class */ 78 | export type ActionCreators = { 79 | [K in keyof Methods>]: ImmerActionCreator< 80 | K, 81 | ArgumentsType[K]> 82 | >; 83 | }; 84 | 85 | /** 86 | * Internal type for the action 87 | */ 88 | type ImmerAction = 89 | | { 90 | type: string; 91 | payload: unknown; 92 | args?: false; 93 | } 94 | | { 95 | type: string; 96 | payload: unknown[]; 97 | args: true; 98 | }; 99 | 100 | /** 101 | * Type guard for detecting actions created by immer reducer 102 | * 103 | * @param action any redux action 104 | * @param immerActionCreator method from a ImmerReducer class 105 | */ 106 | export function isAction>( 107 | action: {type: any}, 108 | immerActionCreator: A, 109 | ): action is ReturnType { 110 | return action.type === immerActionCreator.type; 111 | } 112 | 113 | function isActionFromClass( 114 | action: {type: any}, 115 | immerReducerClass: T, 116 | ): action is Actions { 117 | if (typeof action.type !== "string") { 118 | return false; 119 | } 120 | 121 | if (!action.type.startsWith(actionTypePrefix + ":")) { 122 | return false; 123 | } 124 | 125 | const [className, methodName] = removePrefix(action.type).split("#"); 126 | 127 | if (className !== getReducerName(immerReducerClass)) { 128 | return false; 129 | } 130 | 131 | if (typeof immerReducerClass.prototype[methodName] !== "function") { 132 | return false; 133 | } 134 | 135 | return true; 136 | } 137 | 138 | export function isActionFrom( 139 | action: {type: any}, 140 | immerReducerClass: T, 141 | ): action is Actions { 142 | return isActionFromClass(action, immerReducerClass); 143 | } 144 | 145 | interface Reducer { 146 | (state: State | undefined, action: any): State; 147 | } 148 | 149 | /** 150 | * Combine multiple reducers into a single one 151 | * 152 | * @param reducers two or more reducer 153 | */ 154 | export function composeReducers( 155 | ...reducers: Reducer[] 156 | ): Reducer { 157 | return (state: any, action: any) => { 158 | return ( 159 | reducers.reduce((state, subReducer) => { 160 | if (typeof subReducer === "function") { 161 | return subReducer(state, action); 162 | } 163 | 164 | return state; 165 | }, state) || state 166 | ); 167 | }; 168 | } 169 | 170 | /** The actual ImmerReducer class */ 171 | export class ImmerReducer { 172 | static customName?: string; 173 | readonly state: T; 174 | draftState: Draft; // Make read only states mutable using Draft 175 | 176 | constructor(draftState: Draft, state: T) { 177 | this.state = state; 178 | this.draftState = draftState; 179 | } 180 | } 181 | 182 | function removePrefix(actionType: string) { 183 | return actionType 184 | .split(":") 185 | .slice(1) 186 | .join(":"); 187 | } 188 | 189 | let KNOWN_REDUCER_CLASSES: typeof ImmerReducer[] = []; 190 | 191 | const DUPLICATE_INCREMENTS: {[name: string]: number | undefined} = {}; 192 | 193 | /** 194 | * Set customName for classes automatically if there is multiple reducers 195 | * classes defined with the same name. This can occur accidentaly when using 196 | * name mangling with minifiers. 197 | * 198 | * @param immerReducerClass 199 | */ 200 | function setCustomNameForDuplicates(immerReducerClass: typeof ImmerReducer) { 201 | const hasSetCustomName = KNOWN_REDUCER_CLASSES.find(klass => 202 | Boolean(klass === immerReducerClass), 203 | ); 204 | 205 | if (hasSetCustomName) { 206 | return; 207 | } 208 | 209 | const duplicateCustomName = 210 | immerReducerClass.customName && 211 | KNOWN_REDUCER_CLASSES.find(klass => 212 | Boolean( 213 | klass.customName && 214 | klass.customName === immerReducerClass.customName, 215 | ), 216 | ); 217 | 218 | if (duplicateCustomName) { 219 | throw new Error( 220 | `There is already customName ${immerReducerClass.customName} defined for ${duplicateCustomName.name}`, 221 | ); 222 | } 223 | 224 | const duplicate = KNOWN_REDUCER_CLASSES.find( 225 | klass => klass.name === immerReducerClass.name, 226 | ); 227 | 228 | if (duplicate && !duplicate.customName) { 229 | let number = DUPLICATE_INCREMENTS[immerReducerClass.name]; 230 | 231 | if (number) { 232 | number++; 233 | } else { 234 | number = 1; 235 | } 236 | 237 | DUPLICATE_INCREMENTS[immerReducerClass.name] = number; 238 | 239 | immerReducerClass.customName = immerReducerClass.name + "_" + number; 240 | } 241 | 242 | KNOWN_REDUCER_CLASSES.push(immerReducerClass); 243 | } 244 | 245 | /** 246 | * Convert function arguments to ImmerAction object 247 | */ 248 | function createImmerAction(type: string, args: unknown[]): ImmerAction { 249 | if (args.length === 1) { 250 | return {type, payload: args[0]}; 251 | } 252 | 253 | return { 254 | type, 255 | payload: args, 256 | args: true, 257 | }; 258 | } 259 | 260 | /** 261 | * Get function arguments from the ImmerAction object 262 | */ 263 | function getArgsFromImmerAction(action: ImmerAction): unknown[] { 264 | if (action.args) { 265 | return action.payload; 266 | } 267 | 268 | return [action.payload]; 269 | } 270 | 271 | function getAllPropertyNames(obj: object) { 272 | const proto = Object.getPrototypeOf(obj); 273 | const inherited: string[] = proto ? getAllPropertyNames(proto) : []; 274 | return Object.getOwnPropertyNames(obj) 275 | .concat(inherited) 276 | .filter( 277 | (propertyName, index, uniqueList) => 278 | uniqueList.indexOf(propertyName) === index, 279 | ); 280 | } 281 | 282 | export function createActionCreators( 283 | immerReducerClass: T, 284 | ): ActionCreators { 285 | setCustomNameForDuplicates(immerReducerClass); 286 | 287 | const actionCreators: {[key: string]: Function} = {}; 288 | const immerReducerProperties = getAllPropertyNames(ImmerReducer.prototype); 289 | getAllPropertyNames(immerReducerClass.prototype).forEach(key => { 290 | if (immerReducerProperties.includes(key)) { 291 | return; 292 | } 293 | const method = immerReducerClass.prototype[key]; 294 | 295 | if (typeof method !== "function") { 296 | return; 297 | } 298 | 299 | const type = `${actionTypePrefix}:${getReducerName( 300 | immerReducerClass, 301 | )}#${key}`; 302 | 303 | const actionCreator = (...args: any[]) => { 304 | // Make sure only the arguments are passed to the action object that 305 | // are defined in the method 306 | return createImmerAction(type, args.slice(0, method.length)); 307 | }; 308 | actionCreator.type = type; 309 | actionCreators[key] = actionCreator; 310 | }); 311 | 312 | return actionCreators as any; // typed in the function signature 313 | } 314 | 315 | function getReducerName(klass: {name: string; customName?: string}) { 316 | const name = klass.customName || klass.name; 317 | if (!name) { 318 | throw new Error( 319 | `immer-reducer failed to get reducer name for a class. Try adding 'static customName = "name"'`, 320 | ); 321 | } 322 | return name; 323 | } 324 | 325 | export function createReducerFunction( 326 | immerReducerClass: T, 327 | initialState?: ImmerReducerState, 328 | ): ImmerReducerFunction { 329 | setCustomNameForDuplicates(immerReducerClass); 330 | 331 | return function immerReducerFunction(state, action) { 332 | if (state === undefined) { 333 | state = initialState; 334 | } 335 | 336 | if (!isActionFromClass(action, immerReducerClass)) { 337 | return state; 338 | } 339 | 340 | if (!state) { 341 | throw new Error( 342 | "ImmerReducer does not support undefined state. Pass initial state to createReducerFunction() or createStore()", 343 | ); 344 | } 345 | 346 | const [_, methodName] = removePrefix(action.type as string).split("#"); 347 | 348 | return produce(state, draftState => { 349 | const reducers: any = new immerReducerClass(draftState, state); 350 | 351 | reducers[methodName](...getArgsFromImmerAction(action as any)); 352 | 353 | // The reducer replaced the instance with completely new state so 354 | // make that to be the next state 355 | if (reducers.draftState !== draftState) { 356 | return reducers.draftState; 357 | } 358 | 359 | return draftState; 360 | 361 | // Workaround typing changes in Immer 9.x. This does not actually 362 | // affect the exposed types by immer-reducer itself. 363 | 364 | // Also using immer internally with anys like this allow us to 365 | // support multiple versions of immer. 366 | }) as any; 367 | }; 368 | } 369 | 370 | export function setPrefix(prefix: string): void { 371 | actionTypePrefix = prefix; 372 | } 373 | 374 | /** 375 | * INTERNAL! This is only for tests! 376 | */ 377 | export function _clearKnownClasses() { 378 | KNOWN_REDUCER_CLASSES = []; 379 | } 380 | 381 | /** 382 | * https://webpack.js.org/api/hot-module-replacement/#module-api 383 | */ 384 | interface WebpackModule { 385 | hot?: { 386 | status(): string; 387 | addStatusHandler?: (handler: (status: string) => void) => void; 388 | }; 389 | } 390 | 391 | /** 392 | * Webpack Module global if using Wepback 393 | */ 394 | declare const module: WebpackModule | undefined; 395 | 396 | if (typeof module !== "undefined") { 397 | // Clear classes on Webpack Hot Module replacement as it will mess up the 398 | // duplicate checks appear 399 | module.hot?.addStatusHandler?.(status => { 400 | if (status === "prepare") { 401 | _clearKnownClasses(); 402 | } 403 | }); 404 | } 405 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["__dtslint__"], 4 | "compilerOptions": { 5 | "sourceMap": true, 6 | "noEmit": false, 7 | "outDir": "./build", 8 | "declaration": true, 9 | "declarationDir": "./build" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /tsconfig.dtslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": [] 4 | } 5 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "target": "es5", 5 | "noEmit": true, 6 | "jsx": "react", 7 | "lib": ["esnext", "dom"], 8 | "moduleResolution": "node", 9 | "forceConsistentCasingInFileNames": true, 10 | "strict": true, 11 | "esModuleInterop": true 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "rulesDirectory": "./node_modules/dtslint/bin/rules", 3 | "rules": { 4 | "expect": true 5 | } 6 | } 7 | --------------------------------------------------------------------------------