diff --git a/README.md b/README.md index ece0c45..f994bf5 100644 --- a/README.md +++ b/README.md @@ -24,13 +24,15 @@ You should check Playground Project located in the `/playground` folder. It is a - [Higher-Order Components](#higher-order-components) 📝 __UPDATED__ - [Redux Connected Components](#redux-connected-components) - [Redux](#redux) - - [Action Creators](#action-creators) - - [Reducers](#reducers) - - [Store Configuration](#store-configuration) - - [Async Flow](#async-flow) _("redux-observable")_ - - [Selectors](#selectors) _("reselect")_ + - [Action Creators](#action-creators) 📝 __UPDATED__ + - [Reducers](#reducers) 📝 __UPDATED__ + - [State with Type-level Immutability](#state-with-type-level-immutability) + - [Reducer Example](#reducer-example) + - [Store Configuration](#store-configuration) 📝 __UPDATED__ + - [Async Flow](#async-flow) 📝 __UPDATED__ + - [Selectors](#selectors) - [Tools](#tools) - - [Living Style Guide](#living-style-guide) _("react-styleguidist")_ 🌟 __NEW__ + - [Living Style Guide](#living-style-guide) 🌟 __NEW__ - [Extras](#extras) - [tsconfig.json](#tsconfigjson) - [tslint.json](#tslintjson) @@ -132,9 +134,9 @@ const handleChange: React.ReactEventHandler = (ev) => { ... import * as React from 'react'; export interface SFCCounterProps { - label: string, - count: number, - onIncrement: () => any, + label: string; + count: number; + onIncrement: () => any; } export const SFCCounter: React.SFC = (props) => { @@ -164,8 +166,8 @@ export const SFCCounter: React.SFC = (props) => { import * as React from 'react'; export interface SFCSpreadAttributesProps { - className?: string, - style?: React.CSSProperties, + className?: string; + style?: React.CSSProperties; } export const SFCSpreadAttributes: React.SFC = (props) => { @@ -194,11 +196,11 @@ export const SFCSpreadAttributes: React.SFC = (props) import * as React from 'react'; export interface StatefulCounterProps { - label: string, + label: string; } type State = { - count: number, + count: number; }; export class StatefulCounter extends React.Component { @@ -208,7 +210,7 @@ export class StatefulCounter extends React.Component { this.setState({ count: this.state.count + 1 }); - }; + } render() { const { handleIncrement } = this; @@ -238,18 +240,18 @@ export class StatefulCounter extends React.Component = @@ -272,7 +274,7 @@ export const StatefulCounterWithInitialCount: React.ComponentClass { this.setState({ count: this.state.count + 1 }); - }; + } render() { const { handleIncrement } = this; @@ -308,8 +310,8 @@ export const StatefulCounterWithInitialCount: React.ComponentClass { - items: T[], - itemRenderer: (item: T) => JSX.Element, + items: T[]; + itemRenderer: (item: T) => JSX.Element; } export class GenericList extends React.Component, {}> { @@ -346,19 +348,19 @@ import { Diff as Subtract } from 'react-redux-typescript'; // These props will be subtracted from original component type interface WrappedComponentProps { - count: number, - onIncrement: () => any, + count: number; + onIncrement: () => any; } export const withState =

( - WrappedComponent: React.ComponentType

, + WrappedComponent: React.ComponentType

) => { // These props will be added to original component type interface Props { - initialCount?: number, + initialCount?: number; } interface State { - count: number, + count: number; } return class WithState extends React.Component & Props, State> { @@ -371,7 +373,7 @@ export const withState =

( handleIncrement = () => { this.setState({ count: this.state.count + 1 }); - }; + } render() { const { ...remainingProps } = this.props; @@ -501,21 +503,35 @@ export default (() => ( ## Redux Connected Components +### Caveat with `bindActionCreators` +**If you try to use `connect` or `bindActionCreators` explicitly and want to type your component callback props as `() => void` this will raise compiler errors. I happens because `bindActionCreators` typings will not map the return type of action creators to `void`, due to a current TypeScript limitations.** + +A decent alternative I can recommend is to use `() => any` type, it will work just fine in all possible scenarios and should not cause any typing problems whatsoever. All the code examples in the Guide with `connect` are also using this pattern. + +> If there is any progress or fix in regard to the above caveat I'll update the guide and make an announcement on my twitter/medium (There are a few existing proposals already). + +> There is alternative way to retain type soundness but it requires an explicit wrapping with `dispatch` and will be very tedious for the long run. See example below: +``` +const mapDispatchToProps = (dispatch: Dispatch) => ({ + onIncrement: () => dispatch(actions.increment()), +}); +``` + #### - redux connected counter ```tsx import { connect } from 'react-redux'; import { RootState } from '@src/redux'; -import { actionCreators } from '@src/redux/counters'; +import { actions, CountersSelectors } from '@src/redux/counters'; import { SFCCounter } from '@src/components'; const mapStateToProps = (state: RootState) => ({ - count: state.counters.sfcCounter, + count: CountersSelectors.getReduxCounter(state), }); export const SFCCounterConnected = connect(mapStateToProps, { - onIncrement: actionCreators.incrementSfc, + onIncrement: actions.increment, })(SFCCounter); ``` @@ -544,15 +560,15 @@ import { bindActionCreators } from 'redux'; import { connect } from 'react-redux'; import { RootState, Dispatch } from '@src/redux'; -import { actionCreators } from '@src/redux/counters'; +import { actions } from '@src/redux/counters'; import { SFCCounter } from '@src/components'; const mapStateToProps = (state: RootState) => ({ - count: state.counters.sfcCounter, + count: state.counters.reduxCounter, }); const mapDispatchToProps = (dispatch: Dispatch) => bindActionCreators({ - onIncrement: actionCreators.incrementSfc, + onIncrement: actions.increment, }, dispatch); export const SFCCounterConnectedVerbose = @@ -583,19 +599,19 @@ export default () => ( import { connect } from 'react-redux'; import { RootState } from '@src/redux'; -import { actionCreators } from '@src/redux/counters'; +import { actions, CountersSelectors } from '@src/redux/counters'; import { SFCCounter } from '@src/components'; export interface SFCCounterConnectedExtended { - initialCount: number, + initialCount: number; } const mapStateToProps = (state: RootState, ownProps: SFCCounterConnectedExtended) => ({ - count: state.counters.sfcCounter + ownProps.initialCount, + count: CountersSelectors.getReduxCounter(state) + ownProps.initialCount, }); export const SFCCounterConnectedExtended = connect(mapStateToProps, { - onIncrement: actionCreators.incrementSfc, + onIncrement: actions.increment, })(SFCCounter); ``` @@ -624,38 +640,21 @@ export default () => ( ## Action Creators -### KISS Style -This pattern is focused on a KISS principle - to stay clear of complex proprietary abstractions and follow simple and familiar JavaScript const based types: +> Using Typesafe Action Creators helpers for Redux [`typesafe-actions`](https://github.com/piotrwitek/typesafe-actions) -Advantages: -- simple "const" based types -- familiar to standard JS usage - -Disadvantages: -- significant amount of boilerplate and duplication -- necessary to export both action types and action creators to re-use in other places, e.g. `redux-saga` or `redux-observable` +A recommended approach is to use a simple functional helper to automate the creation of type-safe action creators. The advantage is that we can reduce a lot of code repetition and also minimize surface of errors by using type-checked API. +> There are more specialized functional helpers available that will help you to further reduce tedious boilerplate and type-annotations in common scenarios like reducers (`getType`) or epics (`isActionOf`). +All that without losing type-safety! Please check this very short [Tutorial](https://github.com/piotrwitek/typesafe-actions#tutorial) ```tsx -export const INCREMENT_SFC = 'INCREMENT_SFC'; -export const DECREMENT_SFC = 'DECREMENT_SFC'; - -export type Actions = { - INCREMENT_SFC: { - type: typeof INCREMENT_SFC, - }, - DECREMENT_SFC: { - type: typeof DECREMENT_SFC, - }, -}; - -// Action Creators -export const actionCreators = { - incrementSfc: (): Actions[typeof INCREMENT_SFC] => ({ - type: INCREMENT_SFC, - }), - decrementSfc: (): Actions[typeof DECREMENT_SFC] => ({ - type: DECREMENT_SFC, - }), +import { createAction } from 'typesafe-actions'; + +export const actions = { + increment: createAction('INCREMENT'), + add: createAction('ADD', (amount: number) => ({ + type: 'ADD', + payload: amount, + })), }; ``` @@ -663,66 +662,29 @@ export const actionCreators = { ```tsx import store from '@src/store'; -import { actionCreators } from '@src/redux/counters'; +import { actions } from '@src/redux/counters'; -// store.dispatch(actionCreators.incrementSfc(1)); // Error: Expected 0 arguments, but got 1. -store.dispatch(actionCreators.incrementSfc()); // OK => { type: "INCREMENT_SFC" } +// store.dispatch(actionCreators.increment(1)); // Error: Expected 0 arguments, but got 1. +store.dispatch(actions.increment()); // OK => { type: "INCREMENT" } ```

[⇧ back to top](#table-of-contents) -### DRY Style -In a DRY approach, we're introducing a simple factory function to automate the creation process of type-safe action creators. The advantage here is that we can reduce boilerplate and repetition significantly. It is also easier to re-use action creators in other layers thanks to `getType` helper function returning "type constant". - -Advantages: -- using factory function to automate creation of type-safe action creators -- less boilerplate and code repetition than KISS Style -- getType helper to obtain action creator type (this makes using "type constants" unnecessary) - -```ts -import { createAction, getType } from 'react-redux-typescript'; - -// Action Creators -export const actionCreators = { - incrementCounter: createAction('INCREMENT_COUNTER'), - showNotification: createAction('SHOW_NOTIFICATION', - (message: string, severity: Severity = 'default') => ({ - type: 'SHOW_NOTIFICATION', payload: { message, severity }, - }) - ), -}; - -// Usage -store.dispatch(actionCreators.incrementCounter(4)); // Error: Expected 0 arguments, but got 1. -store.dispatch(actionCreators.incrementCounter()); // OK: { type: "INCREMENT_COUNTER" } -getType(actionCreators.incrementCounter) === "INCREMENT_COUNTER" // true - -store.dispatch(actionCreators.showNotification()); // Error: Supplied parameters do not match any signature of call target. -store.dispatch(actionCreators.showNotification('Hello!')); // OK: { type: "SHOW_NOTIFICATION", payload: { message: 'Hello!', severity: 'default' } } -store.dispatch(actionCreators.showNotification('Hello!', 'info')); // OK: { type: "SHOW_NOTIFICATION", payload: { message: 'Hello!', severity: 'info' } } -getType(actionCreators.showNotification) === "SHOW_NOTIFICATION" // true -``` - -[⇧ back to top](#table-of-contents) - --- ## Reducers -Relevant TypeScript Docs references: -- [Discriminated Union types](https://www.typescriptlang.org/docs/handbook/advanced-types.html) -- [Mapped types](https://www.typescriptlang.org/docs/handbook/advanced-types.html) e.g. `Readonly` & `Partial` -### Tutorial -Declare reducer `State` type definition with readonly modifier for `type level` immutability +### State with Type-level Immutability +Declare reducer `State` type with `readonly` modifier to get "type level" immutability ```ts export type State = { readonly counter: number, }; ``` -Readonly modifier allow initialization, but will not allow rassignment highlighting an error +Readonly modifier allow initialization, but will not allow rassignment by highlighting compiler errors ```ts export const initialState: State = { counter: 0, @@ -731,68 +693,67 @@ export const initialState: State = { initialState.counter = 3; // Error, cannot be mutated ``` -#### Caveat: Readonly does not provide recursive immutability on objects -> This means that readonly modifier does not propagate immutability on nested properties of objects or arrays of objects. You'll need to set it explicitly on each nested property. +#### Caveat: Readonly does not provide a recursive immutability on objects +This means that the `readonly` modifier doesn't propagate immutability down to "properties" of objects. You'll need to set it explicitly on each nested property that you want. +Check the example below: ```ts export type State = { - readonly counterContainer: { - readonly readonlyCounter: number, - mutableCounter: number, + readonly containerObject: { + readonly immutableProp: number, + mutableProp: number, } }; -state.counterContainer = { mutableCounter: 1 }; // Error, cannot be mutated -state.counterContainer.readonlyCounter = 1; // Error, cannot be mutated +state.containerObject = { mutableProp: 1 }; // Error, cannot be mutated +state.containerObject.immutableProp = 1; // Error, cannot be mutated -state.counterContainer.mutableCounter = 1; // No error, can be mutated +state.containerObject.mutableProp = 1; // OK! No error, can be mutated ``` -> There are few utilities to help you achieve nested immutability. e.g. you can do it quite easily by using convenient `Readonly` or `ReadonlyArray` mapped types. +#### Best-practices for nested immutability +> use `Readonly` or `ReadonlyArray` [Mapped types](https://www.typescriptlang.org/docs/handbook/advanced-types.html) ```ts export type State = Readonly<{ - countersCollection: ReadonlyArray>, }>; -state.countersCollection[0] = { readonlyCounter1: 1, readonlyCounter2: 1 }; // Error, cannot be mutated -state.countersCollection[0].readonlyCounter1 = 1; // Error, cannot be mutated -state.countersCollection[0].readonlyCounter2 = 1; // Error, cannot be mutated +state.counterPairs[0] = { immutableCounter1: 1, immutableCounter2: 1 }; // Error, cannot be mutated +state.counterPairs[0].immutableCounter1 = 1; // Error, cannot be mutated +state.counterPairs[0].immutableCounter2 = 1; // Error, cannot be mutated ``` -> _There are some experiments in the community to make a `ReadonlyRecursive` mapped type, but I'll need to investigate if they really works_ +> _There are some experiments in the community to make a `ReadonlyRecursive` mapped type. I'll update this section of the guide as soon as they are stable_ [⇧ back to top](#table-of-contents) -### Examples - -#### Reducer with classic `const types` +### Reducer Example +> using `getType` helper and [Discriminated Union types](https://www.typescriptlang.org/docs/handbook/advanced-types.html) ```tsx import { combineReducers } from 'redux'; +import { getType } from 'typesafe-actions'; import { RootAction } from '@src/redux'; -import { - INCREMENT_SFC, - DECREMENT_SFC, -} from './'; +import { actions } from './'; export type State = { - readonly sfcCounter: number, + readonly reduxCounter: number; }; export const reducer = combineReducers({ - sfcCounter: (state = 0, action) => { + reduxCounter: (state = 0, action) => { switch (action.type) { - case INCREMENT_SFC: + case getType(actions.increment): return state + 1; - case DECREMENT_SFC: - return state + 1; + case getType(actions.add): + return state + action.payload; default: return state; @@ -804,25 +765,6 @@ export const reducer = combineReducers({ [⇧ back to top](#table-of-contents) -#### Reducer with getType helper from `react-redux-typescript` -```ts -import { getType } from 'react-redux-typescript'; - -export const reducer: Reducer = (state = 0, action: RootAction) => { - switch (action.type) { - case getType(actionCreators.increment): - return state + 1; - - case getType(actionCreators.decrement): - return state - 1; - - default: return state; - } -}; -``` - -[⇧ back to top](#table-of-contents) - --- ## Store Configuration @@ -842,9 +784,9 @@ import { reducer as todos, State as TodosState } from '@src/redux/todos'; interface StoreEnhancerState { } export interface RootState extends StoreEnhancerState { - router: RouterState, - counters: CountersState, - todos: TodosState, + router: RouterState; + counters: CountersState; + todos: TodosState; } import { RootAction } from '@src/redux'; @@ -864,18 +806,26 @@ Can be imported in various layers receiving or sending redux actions like: reduc ```tsx // RootActions import { RouterAction, LocationChangeAction } from 'react-router-redux'; +import { getReturnOfExpression } from 'react-redux-typescript'; + +import { actions as countersAC } from '@src/redux/counters'; +import { actions as todosAC } from '@src/redux/todos'; +import { actions as toastsAC } from '@src/redux/toasts'; -import { Actions as CountersActions } from '@src/redux/counters'; -import { Actions as TodosActions } from '@src/redux/todos'; -import { Actions as ToastsActions } from '@src/redux/toasts'; +export const allActions = { + ...countersAC, + ...todosAC, + ...toastsAC, +}; +const returnOfActions = + Object.values(allActions).map(getReturnOfExpression); +type AppAction = typeof returnOfActions[number]; type ReactRouterAction = RouterAction | LocationChangeAction; export type RootAction = - | ReactRouterAction - | CountersActions[keyof CountersActions] - | TodosActions[keyof TodosActions] - | ToastsActions[keyof ToastsActions]; + | AppAction + | ReactRouterAction; ``` @@ -903,13 +853,13 @@ function configureStore(initialState?: RootState) { ]; // compose enhancers const enhancer = composeEnhancers( - applyMiddleware(...middlewares), + applyMiddleware(...middlewares) ); // create store return createStore( rootReducer, initialState!, - enhancer, + enhancer ); } @@ -921,35 +871,38 @@ export default store; ``` -[⇧ back to top](#table-of-contents) - --- ## Async Flow ### "redux-observable" -```ts -// import rxjs operators somewhere... +```tsx import { combineEpics, Epic } from 'redux-observable'; +import { isActionOf } from 'typesafe-actions'; +import { Observable } from 'rxjs/Observable'; +import { v4 } from 'uuid'; -import { RootAction, RootState } from '@src/redux'; -import { saveState } from '@src/services/local-storage-service'; +import { RootAction, RootState, allActions } from '@src/redux'; +import { actions } from './'; -const SAVING_DELAY = 1000; +const TOAST_LIFETIME = 2000; -// persist state in local storage every 1s -const saveStateInLocalStorage: Epic = (action$, store) => action$ - .debounceTime(SAVING_DELAY) - .do((action: RootAction) => { - // handle side-effects - saveState(store.getState()); - }) - .ignoreElements(); +const addTodoToast: Epic = + (action$, store) => action$ + .filter(isActionOf(allActions.addTodo)) + .concatMap((action) => { + const toast = { id: v4(), text: action.payload }; + + const addToast$ = Observable.of(actions.addToast(toast)); + const removeToast$ = Observable.of(actions.removeToast(toast.id)) + .delay(TOAST_LIFETIME); + + return addToast$.concat(removeToast$); + }); + +export const epics = combineEpics(addTodoToast); -export const epics = combineEpics( - saveStateInLocalStorage, -); ``` [⇧ back to top](#table-of-contents) @@ -991,6 +944,45 @@ export const getFilteredTodos = createSelector( --- +### Action Creators - Alternative Pattern +This pattern is focused on a KISS principle - to stay clear of abstractions and to follow a more complex but familiar JavaScript "const" based approach: + +Advantages: +- familiar to standard JS "const" based approach + +Disadvantages: +- significant amount of boilerplate and duplication +- more complex compared to `createAction` helper library +- necessary to export both action types and action creators to re-use in other places, e.g. `redux-saga` or `redux-observable` + +```tsx +export const INCREMENT = 'INCREMENT'; +export const ADD = 'ADD'; + +export type Actions = { + INCREMENT: { + type: typeof INCREMENT, + }, + ADD: { + type: typeof ADD, + payload: number, + }, +}; + +export const actions = { + increment: (): Actions[typeof INCREMENT] => ({ + type: INCREMENT, + }), + add: (amount: number): Actions[typeof ADD] => ({ + type: ADD, + payload: amount, + }), +}; +``` + +[⇧ back to top](#table-of-contents) +--- + # Tools ## Living Style Guide @@ -1044,7 +1036,7 @@ export const getFilteredTodos = createSelector( "noImplicitReturns": true, "noImplicitThis": true, "noUnusedLocals": true, - "strictNullChecks": true, + "strict": true, "pretty": true, "removeComments": true, "sourceMap": true diff --git a/docs/markdown/1_react.md b/docs/markdown/1_react.md index ca658ca..ef93279 100644 --- a/docs/markdown/1_react.md +++ b/docs/markdown/1_react.md @@ -134,6 +134,20 @@ Adds error handling using componentDidCatch to any component ## Redux Connected Components +### Caveat with `bindActionCreators` +**If you try to use `connect` or `bindActionCreators` explicitly and want to type your component callback props as `() => void` this will raise compiler errors. I happens because `bindActionCreators` typings will not map the return type of action creators to `void`, due to a current TypeScript limitations.** + +A decent alternative I can recommend is to use `() => any` type, it will work just fine in all possible scenarios and should not cause any typing problems whatsoever. All the code examples in the Guide with `connect` are also using this pattern. + +> If there is any progress or fix in regard to the above caveat I'll update the guide and make an announcement on my twitter/medium (There are a few existing proposals already). + +> There is alternative way to retain type soundness but it requires an explicit wrapping with `dispatch` and will be very tedious for the long run. See example below: +``` +const mapDispatchToProps = (dispatch: Dispatch) => ({ + onIncrement: () => dispatch(actions.increment()), +}); +``` + #### - redux connected counter ::example='../../playground/src/connected/sfc-counter-connected.tsx':: diff --git a/docs/markdown/2_redux.md b/docs/markdown/2_redux.md index af0fc21..b4b1dd5 100644 --- a/docs/markdown/2_redux.md +++ b/docs/markdown/2_redux.md @@ -2,72 +2,30 @@ ## Action Creators -### KISS Style -This pattern is focused on a KISS principle - to stay clear of complex proprietary abstractions and follow simple and familiar JavaScript const based types: +> Using Typesafe Action Creators helpers for Redux [`typesafe-actions`](https://github.com/piotrwitek/typesafe-actions) -Advantages: -- simple "const" based types -- familiar to standard JS usage - -Disadvantages: -- significant amount of boilerplate and duplication -- necessary to export both action types and action creators to re-use in other places, e.g. `redux-saga` or `redux-observable` +A recommended approach is to use a simple functional helper to automate the creation of type-safe action creators. The advantage is that we can reduce a lot of code repetition and also minimize surface of errors by using type-checked API. +> There are more specialized functional helpers available that will help you to further reduce tedious boilerplate and type-annotations in common scenarios like reducers (`getType`) or epics (`isActionOf`). +All that without losing type-safety! Please check this very short [Tutorial](https://github.com/piotrwitek/typesafe-actions#tutorial) ::example='../../playground/src/redux/counters/actions.ts':: ::usage='../../playground/src/redux/counters/actions.usage.ts':: [⇧ back to top](#table-of-contents) -### DRY Style -In a DRY approach, we're introducing a simple factory function to automate the creation process of type-safe action creators. The advantage here is that we can reduce boilerplate and repetition significantly. It is also easier to re-use action creators in other layers thanks to `getType` helper function returning "type constant". - -Advantages: -- using factory function to automate creation of type-safe action creators -- less boilerplate and code repetition than KISS Style -- getType helper to obtain action creator type (this makes using "type constants" unnecessary) - -```ts -import { createAction, getType } from 'react-redux-typescript'; - -// Action Creators -export const actionCreators = { - incrementCounter: createAction('INCREMENT_COUNTER'), - showNotification: createAction('SHOW_NOTIFICATION', - (message: string, severity: Severity = 'default') => ({ - type: 'SHOW_NOTIFICATION', payload: { message, severity }, - }) - ), -}; - -// Usage -store.dispatch(actionCreators.incrementCounter(4)); // Error: Expected 0 arguments, but got 1. -store.dispatch(actionCreators.incrementCounter()); // OK: { type: "INCREMENT_COUNTER" } -getType(actionCreators.incrementCounter) === "INCREMENT_COUNTER" // true - -store.dispatch(actionCreators.showNotification()); // Error: Supplied parameters do not match any signature of call target. -store.dispatch(actionCreators.showNotification('Hello!')); // OK: { type: "SHOW_NOTIFICATION", payload: { message: 'Hello!', severity: 'default' } } -store.dispatch(actionCreators.showNotification('Hello!', 'info')); // OK: { type: "SHOW_NOTIFICATION", payload: { message: 'Hello!', severity: 'info' } } -getType(actionCreators.showNotification) === "SHOW_NOTIFICATION" // true -``` - -[⇧ back to top](#table-of-contents) - --- ## Reducers -Relevant TypeScript Docs references: -- [Discriminated Union types](https://www.typescriptlang.org/docs/handbook/advanced-types.html) -- [Mapped types](https://www.typescriptlang.org/docs/handbook/advanced-types.html) e.g. `Readonly` & `Partial` -### Tutorial -Declare reducer `State` type definition with readonly modifier for `type level` immutability +### State with Type-level Immutability +Declare reducer `State` type with `readonly` modifier to get "type level" immutability ```ts export type State = { readonly counter: number, }; ``` -Readonly modifier allow initialization, but will not allow rassignment highlighting an error +Readonly modifier allow initialization, but will not allow rassignment by highlighting compiler errors ```ts export const initialState: State = { counter: 0, @@ -76,69 +34,51 @@ export const initialState: State = { initialState.counter = 3; // Error, cannot be mutated ``` -#### Caveat: Readonly does not provide recursive immutability on objects -> This means that readonly modifier does not propagate immutability on nested properties of objects or arrays of objects. You'll need to set it explicitly on each nested property. +#### Caveat: Readonly does not provide a recursive immutability on objects +This means that the `readonly` modifier doesn't propagate immutability down to "properties" of objects. You'll need to set it explicitly on each nested property that you want. +Check the example below: ```ts export type State = { - readonly counterContainer: { - readonly readonlyCounter: number, - mutableCounter: number, + readonly containerObject: { + readonly immutableProp: number, + mutableProp: number, } }; -state.counterContainer = { mutableCounter: 1 }; // Error, cannot be mutated -state.counterContainer.readonlyCounter = 1; // Error, cannot be mutated +state.containerObject = { mutableProp: 1 }; // Error, cannot be mutated +state.containerObject.immutableProp = 1; // Error, cannot be mutated -state.counterContainer.mutableCounter = 1; // No error, can be mutated +state.containerObject.mutableProp = 1; // OK! No error, can be mutated ``` -> There are few utilities to help you achieve nested immutability. e.g. you can do it quite easily by using convenient `Readonly` or `ReadonlyArray` mapped types. +#### Best-practices for nested immutability +> use `Readonly` or `ReadonlyArray` [Mapped types](https://www.typescriptlang.org/docs/handbook/advanced-types.html) ```ts export type State = Readonly<{ - countersCollection: ReadonlyArray>, }>; -state.countersCollection[0] = { readonlyCounter1: 1, readonlyCounter2: 1 }; // Error, cannot be mutated -state.countersCollection[0].readonlyCounter1 = 1; // Error, cannot be mutated -state.countersCollection[0].readonlyCounter2 = 1; // Error, cannot be mutated +state.counterPairs[0] = { immutableCounter1: 1, immutableCounter2: 1 }; // Error, cannot be mutated +state.counterPairs[0].immutableCounter1 = 1; // Error, cannot be mutated +state.counterPairs[0].immutableCounter2 = 1; // Error, cannot be mutated ``` -> _There are some experiments in the community to make a `ReadonlyRecursive` mapped type, but I'll need to investigate if they really works_ +> _There are some experiments in the community to make a `ReadonlyRecursive` mapped type. I'll update this section of the guide as soon as they are stable_ [⇧ back to top](#table-of-contents) -### Examples - -#### Reducer with classic `const types` +### Reducer Example +> using `getType` helper and [Discriminated Union types](https://www.typescriptlang.org/docs/handbook/advanced-types.html) ::example='../../playground/src/redux/counters/reducer.ts':: [⇧ back to top](#table-of-contents) -#### Reducer with getType helper from `react-redux-typescript` -```ts -import { getType } from 'react-redux-typescript'; - -export const reducer: Reducer = (state = 0, action: RootAction) => { - switch (action.type) { - case getType(actionCreators.increment): - return state + 1; - - case getType(actionCreators.decrement): - return state - 1; - - default: return state; - } -}; -``` - -[⇧ back to top](#table-of-contents) - --- ## Store Configuration @@ -166,36 +106,13 @@ When creating the store, use rootReducer. This will set-up a **strongly typed St ::example='../../playground/src/store.ts':: -[⇧ back to top](#table-of-contents) - --- ## Async Flow ### "redux-observable" -```ts -// import rxjs operators somewhere... -import { combineEpics, Epic } from 'redux-observable'; - -import { RootAction, RootState } from '@src/redux'; -import { saveState } from '@src/services/local-storage-service'; - -const SAVING_DELAY = 1000; - -// persist state in local storage every 1s -const saveStateInLocalStorage: Epic = (action$, store) => action$ - .debounceTime(SAVING_DELAY) - .do((action: RootAction) => { - // handle side-effects - saveState(store.getState()); - }) - .ignoreElements(); - -export const epics = combineEpics( - saveStateInLocalStorage, -); -``` +::example='../../playground/src/redux/toasts/epics.ts':: [⇧ back to top](#table-of-contents) @@ -233,3 +150,43 @@ export const getFilteredTodos = createSelector( ``` [⇧ back to top](#table-of-contents) + +--- + +### Action Creators - Alternative Pattern +This pattern is focused on a KISS principle - to stay clear of abstractions and to follow a more complex but familiar JavaScript "const" based approach: + +Advantages: +- familiar to standard JS "const" based approach + +Disadvantages: +- significant amount of boilerplate and duplication +- more complex compared to `createAction` helper library +- necessary to export both action types and action creators to re-use in other places, e.g. `redux-saga` or `redux-observable` + +```tsx +export const INCREMENT = 'INCREMENT'; +export const ADD = 'ADD'; + +export type Actions = { + INCREMENT: { + type: typeof INCREMENT, + }, + ADD: { + type: typeof ADD, + payload: number, + }, +}; + +export const actions = { + increment: (): Actions[typeof INCREMENT] => ({ + type: INCREMENT, + }), + add: (amount: number): Actions[typeof ADD] => ({ + type: ADD, + payload: amount, + }), +}; +``` + +[⇧ back to top](#table-of-contents) \ No newline at end of file diff --git a/docs/markdown/4_extras.md b/docs/markdown/4_extras.md index 0dc3336..cb7bd18 100644 --- a/docs/markdown/4_extras.md +++ b/docs/markdown/4_extras.md @@ -38,7 +38,7 @@ "noImplicitReturns": true, "noImplicitThis": true, "noUnusedLocals": true, - "strictNullChecks": true, + "strict": true, "pretty": true, "removeComments": true, "sourceMap": true diff --git a/docs/markdown/_toc.md b/docs/markdown/_toc.md index f353fcf..d8a73e4 100644 --- a/docs/markdown/_toc.md +++ b/docs/markdown/_toc.md @@ -8,13 +8,15 @@ - [Higher-Order Components](#higher-order-components) 📝 __UPDATED__ - [Redux Connected Components](#redux-connected-components) - [Redux](#redux) - - [Action Creators](#action-creators) - - [Reducers](#reducers) - - [Store Configuration](#store-configuration) - - [Async Flow](#async-flow) _("redux-observable")_ - - [Selectors](#selectors) _("reselect")_ + - [Action Creators](#action-creators) 📝 __UPDATED__ + - [Reducers](#reducers) 📝 __UPDATED__ + - [State with Type-level Immutability](#state-with-type-level-immutability) + - [Reducer Example](#reducer-example) + - [Store Configuration](#store-configuration) 📝 __UPDATED__ + - [Async Flow](#async-flow) 📝 __UPDATED__ + - [Selectors](#selectors) - [Tools](#tools) - - [Living Style Guide](#living-style-guide) _("react-styleguidist")_ 🌟 __NEW__ + - [Living Style Guide](#living-style-guide) 🌟 __NEW__ - [Extras](#extras) - [tsconfig.json](#tsconfigjson) - [tslint.json](#tslintjson) diff --git a/playground/.vscode/settings.json b/playground/.vscode/settings.json new file mode 100644 index 0000000..3662b37 --- /dev/null +++ b/playground/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "typescript.tsdk": "node_modules/typescript/lib" +} \ No newline at end of file diff --git a/playground/package.json b/playground/package.json index 4cdb429..9a46bc7 100644 --- a/playground/package.json +++ b/playground/package.json @@ -28,8 +28,9 @@ "redux": "3.7.2", "redux-observable": "0.17.0", "reselect": "3.0.1", - "rxjs": "5.5.5", + "rxjs": "5.5.6", "tslib": "1.8.1", + "typesafe-actions": "1.0.0-rc.1", "uuid": "3.1.0" }, "devDependencies": { diff --git a/playground/src/api/models.ts b/playground/src/api/models.ts index cda9165..500ff75 100644 --- a/playground/src/api/models.ts +++ b/playground/src/api/models.ts @@ -1,5 +1,5 @@ export interface ITodoModel { - id: string, - text: string, - completed: false, + id: string; + text: string; + completed: false; } diff --git a/playground/src/api/utils.ts b/playground/src/api/utils.ts index 3f2c1d0..b77196a 100644 --- a/playground/src/api/utils.ts +++ b/playground/src/api/utils.ts @@ -1,5 +1,5 @@ export const resolveWithDelay = (value: T, time: number = 1000) => new Promise( - (resolve) => setTimeout(() => resolve(value), time), + (resolve) => setTimeout(() => resolve(value), time) ); export const rangeQueryString = (count: number, pageNumber?: number) => diff --git a/playground/src/components/generic-list.tsx b/playground/src/components/generic-list.tsx index bcfbf1e..2d4aeeb 100644 --- a/playground/src/components/generic-list.tsx +++ b/playground/src/components/generic-list.tsx @@ -1,8 +1,8 @@ import * as React from 'react'; export interface GenericListProps { - items: T[], - itemRenderer: (item: T) => JSX.Element, + items: T[]; + itemRenderer: (item: T) => JSX.Element; } export class GenericList extends React.Component, {}> { diff --git a/playground/src/components/sfc-counter.tsx b/playground/src/components/sfc-counter.tsx index 017784c..a75b0a4 100644 --- a/playground/src/components/sfc-counter.tsx +++ b/playground/src/components/sfc-counter.tsx @@ -1,9 +1,9 @@ import * as React from 'react'; export interface SFCCounterProps { - label: string, - count: number, - onIncrement: () => any, + label: string; + count: number; + onIncrement: () => any; } export const SFCCounter: React.SFC = (props) => { diff --git a/playground/src/components/sfc-spread-attributes.tsx b/playground/src/components/sfc-spread-attributes.tsx index a321df9..4660a07 100644 --- a/playground/src/components/sfc-spread-attributes.tsx +++ b/playground/src/components/sfc-spread-attributes.tsx @@ -1,8 +1,8 @@ import * as React from 'react'; export interface SFCSpreadAttributesProps { - className?: string, - style?: React.CSSProperties, + className?: string; + style?: React.CSSProperties; } export const SFCSpreadAttributes: React.SFC = (props) => { diff --git a/playground/src/components/stateful-counter-with-initial-count.tsx b/playground/src/components/stateful-counter-with-initial-count.tsx index fffe4ca..ee36b66 100644 --- a/playground/src/components/stateful-counter-with-initial-count.tsx +++ b/playground/src/components/stateful-counter-with-initial-count.tsx @@ -1,18 +1,18 @@ import * as React from 'react'; export interface StatefulCounterWithInitialCountProps { - label: string, - initialCount?: number, + label: string; + initialCount?: number; } interface DefaultProps { - initialCount: number, + initialCount: number; } type PropsWithDefaults = StatefulCounterWithInitialCountProps & DefaultProps; interface State { - count: number, + count: number; } export const StatefulCounterWithInitialCount: React.ComponentClass = @@ -35,7 +35,7 @@ export const StatefulCounterWithInitialCount: React.ComponentClass { this.setState({ count: this.state.count + 1 }); - }; + } render() { const { handleIncrement } = this; diff --git a/playground/src/components/stateful-counter.tsx b/playground/src/components/stateful-counter.tsx index 81d4f6e..de8af63 100644 --- a/playground/src/components/stateful-counter.tsx +++ b/playground/src/components/stateful-counter.tsx @@ -1,11 +1,11 @@ import * as React from 'react'; export interface StatefulCounterProps { - label: string, + label: string; } type State = { - count: number, + count: number; }; export class StatefulCounter extends React.Component { @@ -15,7 +15,7 @@ export class StatefulCounter extends React.Component { this.setState({ count: this.state.count + 1 }); - }; + } render() { const { handleIncrement } = this; diff --git a/playground/src/connected/sfc-counter-connected-extended.tsx b/playground/src/connected/sfc-counter-connected-extended.tsx index 0c9f7e0..d798a19 100644 --- a/playground/src/connected/sfc-counter-connected-extended.tsx +++ b/playground/src/connected/sfc-counter-connected-extended.tsx @@ -1,17 +1,17 @@ import { connect } from 'react-redux'; import { RootState } from '@src/redux'; -import { actionCreators } from '@src/redux/counters'; +import { actions, CountersSelectors } from '@src/redux/counters'; import { SFCCounter } from '@src/components'; export interface SFCCounterConnectedExtended { - initialCount: number, + initialCount: number; } const mapStateToProps = (state: RootState, ownProps: SFCCounterConnectedExtended) => ({ - count: state.counters.sfcCounter + ownProps.initialCount, + count: CountersSelectors.getReduxCounter(state) + ownProps.initialCount, }); export const SFCCounterConnectedExtended = connect(mapStateToProps, { - onIncrement: actionCreators.incrementSfc, + onIncrement: actions.increment, })(SFCCounter); diff --git a/playground/src/connected/sfc-counter-connected-verbose.tsx b/playground/src/connected/sfc-counter-connected-verbose.tsx index 31ddf87..64f021d 100644 --- a/playground/src/connected/sfc-counter-connected-verbose.tsx +++ b/playground/src/connected/sfc-counter-connected-verbose.tsx @@ -2,15 +2,15 @@ import { bindActionCreators } from 'redux'; import { connect } from 'react-redux'; import { RootState, Dispatch } from '@src/redux'; -import { actionCreators } from '@src/redux/counters'; +import { actions } from '@src/redux/counters'; import { SFCCounter } from '@src/components'; const mapStateToProps = (state: RootState) => ({ - count: state.counters.sfcCounter, + count: state.counters.reduxCounter, }); const mapDispatchToProps = (dispatch: Dispatch) => bindActionCreators({ - onIncrement: actionCreators.incrementSfc, + onIncrement: actions.increment, }, dispatch); export const SFCCounterConnectedVerbose = diff --git a/playground/src/connected/sfc-counter-connected.tsx b/playground/src/connected/sfc-counter-connected.tsx index 0444649..4a8f86b 100644 --- a/playground/src/connected/sfc-counter-connected.tsx +++ b/playground/src/connected/sfc-counter-connected.tsx @@ -1,13 +1,13 @@ import { connect } from 'react-redux'; import { RootState } from '@src/redux'; -import { actionCreators } from '@src/redux/counters'; +import { actions, CountersSelectors } from '@src/redux/counters'; import { SFCCounter } from '@src/components'; const mapStateToProps = (state: RootState) => ({ - count: state.counters.sfcCounter, + count: CountersSelectors.getReduxCounter(state), }); export const SFCCounterConnected = connect(mapStateToProps, { - onIncrement: actionCreators.incrementSfc, + onIncrement: actions.increment, })(SFCCounter); diff --git a/playground/src/hoc/with-state.tsx b/playground/src/hoc/with-state.tsx index b47117b..b8127a6 100644 --- a/playground/src/hoc/with-state.tsx +++ b/playground/src/hoc/with-state.tsx @@ -3,19 +3,19 @@ import { Diff as Subtract } from 'react-redux-typescript'; // These props will be subtracted from original component type interface WrappedComponentProps { - count: number, - onIncrement: () => any, + count: number; + onIncrement: () => any; } export const withState =

( - WrappedComponent: React.ComponentType

, + WrappedComponent: React.ComponentType

) => { // These props will be added to original component type interface Props { - initialCount?: number, + initialCount?: number; } interface State { - count: number, + count: number; } return class WithState extends React.Component & Props, State> { @@ -28,7 +28,7 @@ export const withState =

( handleIncrement = () => { this.setState({ count: this.state.count + 1 }); - }; + } render() { const { ...remainingProps } = this.props; diff --git a/playground/src/models/user.ts b/playground/src/models/user.ts index d23f8db..c99f082 100644 --- a/playground/src/models/user.ts +++ b/playground/src/models/user.ts @@ -9,7 +9,7 @@ export interface IUserDTO { export interface IUser { constructor: { create(user: IUserDTO): IUser; - } + }; id: string; firstName: string; diff --git a/playground/src/redux/app/epics.ts b/playground/src/redux/app/epics.ts index b750636..5779fb8 100644 --- a/playground/src/redux/app/epics.ts +++ b/playground/src/redux/app/epics.ts @@ -8,12 +8,12 @@ const SAVING_DELAY = 1000; // persist state in local storage every 1s const saveStateInLocalStorage: Epic = (action$, store) => action$ .debounceTime(SAVING_DELAY) - .do((action: RootAction) => { + .do((action) => { // handle side-effects saveState(store.getState()); }) .ignoreElements(); export const epics = combineEpics( - saveStateInLocalStorage, + saveStateInLocalStorage ); diff --git a/playground/src/redux/counters/actions.ts b/playground/src/redux/counters/actions.ts index 93e953d..b557e2e 100644 --- a/playground/src/redux/counters/actions.ts +++ b/playground/src/redux/counters/actions.ts @@ -1,21 +1,9 @@ -export const INCREMENT_SFC = 'INCREMENT_SFC'; -export const DECREMENT_SFC = 'DECREMENT_SFC'; +import { createAction } from 'typesafe-actions'; -export type Actions = { - INCREMENT_SFC: { - type: typeof INCREMENT_SFC, - }, - DECREMENT_SFC: { - type: typeof DECREMENT_SFC, - }, -}; - -// Action Creators -export const actionCreators = { - incrementSfc: (): Actions[typeof INCREMENT_SFC] => ({ - type: INCREMENT_SFC, - }), - decrementSfc: (): Actions[typeof DECREMENT_SFC] => ({ - type: DECREMENT_SFC, - }), +export const actions = { + increment: createAction('INCREMENT'), + add: createAction('ADD', (amount: number) => ({ + type: 'ADD', + payload: amount, + })), }; diff --git a/playground/src/redux/counters/actions.usage.ts b/playground/src/redux/counters/actions.usage.ts index 7e3da0e..585780b 100644 --- a/playground/src/redux/counters/actions.usage.ts +++ b/playground/src/redux/counters/actions.usage.ts @@ -1,5 +1,5 @@ import store from '@src/store'; -import { actionCreators } from '@src/redux/counters'; +import { actions } from '@src/redux/counters'; -// store.dispatch(actionCreators.incrementSfc(1)); // Error: Expected 0 arguments, but got 1. -store.dispatch(actionCreators.incrementSfc()); // OK => { type: "INCREMENT_SFC" } +// store.dispatch(actionCreators.increment(1)); // Error: Expected 0 arguments, but got 1. +store.dispatch(actions.increment()); // OK => { type: "INCREMENT" } diff --git a/playground/src/redux/counters/reducer.ts b/playground/src/redux/counters/reducer.ts index f315dad..aab7a81 100644 --- a/playground/src/redux/counters/reducer.ts +++ b/playground/src/redux/counters/reducer.ts @@ -1,24 +1,22 @@ import { combineReducers } from 'redux'; +import { getType } from 'typesafe-actions'; import { RootAction } from '@src/redux'; -import { - INCREMENT_SFC, - DECREMENT_SFC, -} from './'; +import { actions } from './'; export type State = { - readonly sfcCounter: number, + readonly reduxCounter: number; }; export const reducer = combineReducers({ - sfcCounter: (state = 0, action) => { + reduxCounter: (state = 0, action) => { switch (action.type) { - case INCREMENT_SFC: + case getType(actions.increment): return state + 1; - case DECREMENT_SFC: - return state + 1; + case getType(actions.add): + return state + action.payload; default: return state; diff --git a/playground/src/redux/counters/selectors.ts b/playground/src/redux/counters/selectors.ts index ff52b81..adbba71 100644 --- a/playground/src/redux/counters/selectors.ts +++ b/playground/src/redux/counters/selectors.ts @@ -1,4 +1,4 @@ import { RootState } from '@src/redux'; -export const getSfcCounter = - (state: RootState) => state.counters.sfcCounter; +export const getReduxCounter = + (state: RootState) => state.counters.reduxCounter; diff --git a/playground/src/redux/root-action.ts b/playground/src/redux/root-action.ts index 4ac4dde..cad7677 100644 --- a/playground/src/redux/root-action.ts +++ b/playground/src/redux/root-action.ts @@ -1,14 +1,22 @@ // RootActions import { RouterAction, LocationChangeAction } from 'react-router-redux'; +import { getReturnOfExpression } from 'react-redux-typescript'; -import { Actions as CountersActions } from '@src/redux/counters'; -import { Actions as TodosActions } from '@src/redux/todos'; -import { Actions as ToastsActions } from '@src/redux/toasts'; +import { actions as countersAC } from '@src/redux/counters'; +import { actions as todosAC } from '@src/redux/todos'; +import { actions as toastsAC } from '@src/redux/toasts'; +export const allActions = { + ...countersAC, + ...todosAC, + ...toastsAC, +}; + +const returnOfActions = + Object.values(allActions).map(getReturnOfExpression); +type AppAction = typeof returnOfActions[number]; type ReactRouterAction = RouterAction | LocationChangeAction; export type RootAction = - | ReactRouterAction - | CountersActions[keyof CountersActions] - | TodosActions[keyof TodosActions] - | ToastsActions[keyof ToastsActions]; + | AppAction + | ReactRouterAction; diff --git a/playground/src/redux/root-epic.ts b/playground/src/redux/root-epic.ts index a31e615..6b6d301 100644 --- a/playground/src/redux/root-epic.ts +++ b/playground/src/redux/root-epic.ts @@ -3,5 +3,5 @@ import { combineEpics } from 'redux-observable'; import { epics as toasts } from './toasts/epics'; export const rootEpic = combineEpics( - toasts, + toasts ); diff --git a/playground/src/redux/root-reducer.ts b/playground/src/redux/root-reducer.ts index cafa6ed..844b09a 100644 --- a/playground/src/redux/root-reducer.ts +++ b/playground/src/redux/root-reducer.ts @@ -7,9 +7,9 @@ import { reducer as todos, State as TodosState } from '@src/redux/todos'; interface StoreEnhancerState { } export interface RootState extends StoreEnhancerState { - router: RouterState, - counters: CountersState, - todos: TodosState, + router: RouterState; + counters: CountersState; + todos: TodosState; } import { RootAction } from '@src/redux'; diff --git a/playground/src/redux/toasts/actions.ts b/playground/src/redux/toasts/actions.ts index 4257e41..60a4fa7 100644 --- a/playground/src/redux/toasts/actions.ts +++ b/playground/src/redux/toasts/actions.ts @@ -1,19 +1,17 @@ -export const ADD_TOAST = 'ADD_TOAST'; -export const REMOVE_TOAST = 'REMOVE_TOAST'; +import { createAction } from 'typesafe-actions'; -export type IToast = { id: string, text: string }; - -export type Actions = { - ADD_TOAST: { type: typeof ADD_TOAST, payload: IToast }, - REMOVE_TOAST: { type: typeof REMOVE_TOAST, payload: string }, +export type IToast = { + id: string; + text: string; }; -// Action Creators -export const actionCreators = { - addToast: (payload: IToast): Actions[typeof ADD_TOAST] => ({ - type: ADD_TOAST, payload, - }), - removeToast: (payload: string): Actions[typeof REMOVE_TOAST] => ({ - type: REMOVE_TOAST, payload, - }), +export const actions = { + addToast: createAction('ADD_TOAST', (toast: IToast) => ({ + type: 'ADD_TOAST', + payload: toast, + })), + removeToast: createAction('REMOVE_TOAST', (id: string) => ({ + type: 'REMOVE_TOAST', + payload: id, + })), }; diff --git a/playground/src/redux/toasts/epics.ts b/playground/src/redux/toasts/epics.ts index 4f50bfa..4aa34f7 100644 --- a/playground/src/redux/toasts/epics.ts +++ b/playground/src/redux/toasts/epics.ts @@ -1,22 +1,21 @@ -import { v4 } from 'uuid'; -import { Observable } from 'rxjs/Observable'; import { combineEpics, Epic } from 'redux-observable'; +import { isActionOf } from 'typesafe-actions'; +import { Observable } from 'rxjs/Observable'; +import { v4 } from 'uuid'; -import { RootAction, RootState } from '@src/redux'; -import { ADD_TODO, Actions } from '@src/redux/todos'; -import { actionCreators } from './'; +import { RootAction, RootState, allActions } from '@src/redux'; +import { actions } from './'; const TOAST_LIFETIME = 2000; -// Epics - handling side effects of actions const addTodoToast: Epic = (action$, store) => action$ - .ofType(ADD_TODO) - .concatMap((action: Actions[typeof ADD_TODO]) => { + .filter(isActionOf(allActions.addTodo)) + .concatMap((action) => { const toast = { id: v4(), text: action.payload }; - const addToast$ = Observable.of(actionCreators.addToast(toast)); - const removeToast$ = Observable.of(actionCreators.removeToast(toast.id)) + const addToast$ = Observable.of(actions.addToast(toast)); + const removeToast$ = Observable.of(actions.removeToast(toast.id)) .delay(TOAST_LIFETIME); return addToast$.concat(removeToast$); diff --git a/playground/src/redux/todos/actions.ts b/playground/src/redux/todos/actions.ts index 2b3356d..b891a5c 100644 --- a/playground/src/redux/todos/actions.ts +++ b/playground/src/redux/todos/actions.ts @@ -1,24 +1,18 @@ -import { ITodosFilter } from './'; - -export const ADD_TODO = 'ADD_TODO'; -export const TOGGLE_TODO = 'TOGGLE_TODO'; -export const CHANGE_TODOS_FILTER = 'CHANGE_TODOS_FILTER'; +import { createAction } from 'typesafe-actions'; -export type Actions = { - ADD_TODO: { type: typeof ADD_TODO, payload: string }, - TOGGLE_TODO: { type: typeof TOGGLE_TODO, payload: string }, - CHANGE_TODOS_FILTER: { type: typeof CHANGE_TODOS_FILTER, payload: ITodosFilter }, -}; +import { ITodosFilter } from './'; -// Action Creators -export const actionCreators = { - addTodo: (payload: string): Actions[typeof ADD_TODO] => ({ - type: ADD_TODO, payload, - }), - toggleTodo: (payload: string): Actions[typeof TOGGLE_TODO] => ({ - type: TOGGLE_TODO, payload, - }), - changeFilter: (payload: ITodosFilter): Actions[typeof CHANGE_TODOS_FILTER] => ({ - type: CHANGE_TODOS_FILTER, payload, - }), +export const actions = { + addTodo: createAction('ADD_TODO', (text: string) => ({ + type: 'ADD_TODO', + payload: text, + })), + toggleTodo: createAction('TOGGLE_TODO', (id: string) => ({ + type: 'TOGGLE_TODO', + payload: id, + })), + changeFilter: createAction('CHANGE_FILTER', (filter: ITodosFilter) => ({ + type: 'CHANGE_FILTER', + payload: filter, + })), }; diff --git a/playground/src/redux/todos/reducer.ts b/playground/src/redux/todos/reducer.ts index 8974ccf..7681e1c 100644 --- a/playground/src/redux/todos/reducer.ts +++ b/playground/src/redux/todos/reducer.ts @@ -1,21 +1,16 @@ import { v4 } from 'uuid'; import { combineReducers } from 'redux'; +import { getType } from 'typesafe-actions'; import { RootAction } from '@src/redux'; -import { - ADD_TODO, - TOGGLE_TODO, - CHANGE_TODOS_FILTER, - ITodo, - ITodosFilter, -} from './'; +import { actions, ITodo, ITodosFilter } from './'; export type State = { - readonly isFetching: boolean, - readonly errorMessage: string | null, - readonly todos: ITodo[], - readonly todosFilter: ITodosFilter, + readonly isFetching: boolean; + readonly errorMessage: string | null; + readonly todos: ITodo[]; + readonly todosFilter: ITodosFilter; }; export const reducer = combineReducers({ @@ -33,20 +28,18 @@ export const reducer = combineReducers({ }, todos: (state = [], action) => { switch (action.type) { - case ADD_TODO: + case getType(actions.addTodo): return [...state, { id: v4(), title: action.payload, completed: false, }]; - case TOGGLE_TODO: - return state.map((item) => { - if (item.id === action.payload) { - item.completed = !item.completed; - } - return item; - }); + case getType(actions.toggleTodo): + return state.map((item) => item.id === action.payload + ? { ...item, completed: !item.completed } + : item + ); default: return state; @@ -54,7 +47,7 @@ export const reducer = combineReducers({ }, todosFilter: (state = '', action) => { switch (action.type) { - case CHANGE_TODOS_FILTER: + case getType(actions.changeFilter): return action.payload; default: diff --git a/playground/src/redux/todos/selectors.ts b/playground/src/redux/todos/selectors.ts index 14adf53..41f43af 100644 --- a/playground/src/redux/todos/selectors.ts +++ b/playground/src/redux/todos/selectors.ts @@ -20,5 +20,5 @@ export const getFilteredTodos = createSelector( default: return todos; } - }, + } ); diff --git a/playground/src/redux/todos/types.ts b/playground/src/redux/todos/types.ts index c110581..f8c8ee8 100644 --- a/playground/src/redux/todos/types.ts +++ b/playground/src/redux/todos/types.ts @@ -1,7 +1,7 @@ export type ITodo = { - id: string, - title: string, - completed: boolean, + id: string; + title: string; + completed: boolean; }; export type ITodosFilter = diff --git a/playground/src/redux/types.ts b/playground/src/redux/types.ts index 5bc2dd6..2c2a508 100644 --- a/playground/src/redux/types.ts +++ b/playground/src/redux/types.ts @@ -12,12 +12,3 @@ export type Api = {}; // import { ThunkAction as ReduxThunkAction } from 'redux-thunk'; // export type ThunkAction = ReduxThunkAction; - -// OLD ACTION MERGING -// import { returntypeof } from 'react-redux-typescript'; - -// const actions = Object.values({ -// ...converterActionCreators, -// }).map(returntypeof); - -// export type IRootAction = typeof actions[number]; diff --git a/playground/src/store.ts b/playground/src/store.ts index d3f3216..972a881 100644 --- a/playground/src/store.ts +++ b/playground/src/store.ts @@ -14,13 +14,13 @@ function configureStore(initialState?: RootState) { ]; // compose enhancers const enhancer = composeEnhancers( - applyMiddleware(...middlewares), + applyMiddleware(...middlewares) ); // create store return createStore( rootReducer, initialState!, - enhancer, + enhancer ); } diff --git a/playground/tsconfig.json b/playground/tsconfig.json index 06979d9..554632f 100644 --- a/playground/tsconfig.json +++ b/playground/tsconfig.json @@ -31,7 +31,8 @@ "noImplicitReturns": true, "noImplicitThis": true, "noUnusedLocals": true, - "strictNullChecks": true, + "strict": true, + "strictFunctionTypes": false, "pretty": true, "removeComments": true, "sourceMap": true diff --git a/playground/tslint.json b/playground/tslint.json index 5287a11..5ca7487 100644 --- a/playground/tslint.json +++ b/playground/tslint.json @@ -1,104 +1,51 @@ { - "extends": [ - "tslint:recommended", - "tslint-react" - ], + "extends": ["tslint:recommended", "tslint-react"], "rules": { "arrow-parens": false, - "arrow-return-shorthand": [ - false - ], - "comment-format": [ - true, - "check-space" - ], - "import-blacklist": [ - true, - "rxjs" - ], + "arrow-return-shorthand": [false], + "comment-format": [true, "check-space"], + "import-blacklist": [true, "rxjs"], "interface-over-type-literal": false, "interface-name": false, - "jsx-no-lambda": false, - "max-classes-per-file": [ - false - ], - "max-line-length": [ - true, - 120 - ], + "max-line-length": [true, 120], "member-access": false, - "member-ordering": [ - true, - { - "order": "fields-first" - } - ], + "member-ordering": [true, { "order": "fields-first" }], "newline-before-return": false, "no-any": false, "no-empty-interface": false, "no-import-side-effect": [true], - "no-inferrable-types": [ - true, - "ignore-params", - "ignore-properties" - ], - "no-invalid-this": [ - true, - "check-function-in-method" - ], + "no-inferrable-types": [true, "ignore-params", "ignore-properties"], + "no-invalid-this": [true, "check-function-in-method"], "no-null-keyword": false, - "no-object-literal-type-assertion": false, "no-require-imports": false, - "no-switch-case-fall-through": true, - "no-submodule-imports": [true, "rxjs", "@src"], - "no-this-assignment": [true, {"allow-destructuring": true}], + "no-submodule-imports": [true, "@src", "rxjs"], + "no-this-assignment": [true, { "allow-destructuring": true }], "no-trailing-whitespace": true, - "no-unused-variable": [ - true, - "react" - ], + "no-unused-variable": [true, "react"], "object-literal-sort-keys": false, "object-literal-shorthand": false, - "one-variable-per-declaration": [ - false - ], - "only-arrow-functions": [ - true, - "allow-declarations" - ], - "ordered-imports": [ - false - ], + "one-variable-per-declaration": [false], + "only-arrow-functions": [true, "allow-declarations"], + "ordered-imports": [false], "prefer-method-signature": false, - "prefer-template": [ - true, - "allow-single-concat" - ], - "quotemark": [ - true, - "single", - "jsx-double" - ], - "semicolon": [ - true, - "ignore-interfaces", - "ignore-bound-class-methods" - ], - "triple-equals": [ - true, - "allow-null-check" - ], - "typedef": [ - true, - "parameter", - "property-declaration" - ], - "variable-name": [ - true, - "ban-keywords", - "check-format", - "allow-pascal-case", - "allow-leading-underscore" - ] - } -} + "prefer-template": [true, "allow-single-concat"], + "quotemark": [true, "single", "jsx-double"], + "semicolon": [true, "always"], + "trailing-comma": [true, { + "singleline": "never", + "multiline": { + "objects": "always", + "arrays": "always", + "functions": "never", + "typeLiterals": "ignore" + }, + "esSpecCompliant": true + }], + "triple-equals": [true, "allow-null-check"], + "type-literal-delimiter": true, + "typedef": [true,"parameter", "property-declaration"], + "variable-name": [true, "ban-keywords", "check-format", "allow-pascal-case", "allow-leading-underscore"], + // tslint-react + "jsx-no-lambda": false + } + } \ No newline at end of file diff --git a/playground/yarn.lock b/playground/yarn.lock index 0728287..5d3a1ad 100644 --- a/playground/yarn.lock +++ b/playground/yarn.lock @@ -6,7 +6,11 @@ version "4.6.2" resolved "https://registry.yarnpkg.com/@types/history/-/history-4.6.2.tgz#12cfaba693ba20f114ed5765467ff25fdf67ddb0" -"@types/node@*", "@types/node@8.5.1": +"@types/node@*": + version "8.5.2" + resolved "https://registry.yarnpkg.com/@types/node/-/node-8.5.2.tgz#83b8103fa9a2c2e83d78f701a9aa7c9539739aa5" + +"@types/node@8.5.1": version "8.5.1" resolved "https://registry.yarnpkg.com/@types/node/-/node-8.5.1.tgz#4ec3020bcdfe2abffeef9ba3fbf26fca097514b5" @@ -41,8 +45,8 @@ redux "^3.7.2" "@types/react-router@*": - version "4.0.19" - resolved "https://registry.yarnpkg.com/@types/react-router/-/react-router-4.0.19.tgz#4258eb59a9c3a01b5adf1bf9b14f068a7699bbb6" + version "4.0.20" + resolved "https://registry.yarnpkg.com/@types/react-router/-/react-router-4.0.20.tgz#3404f54e44bba2239ea4320ea701d86d92f05486" dependencies: "@types/history" "*" "@types/react" "*" @@ -367,6 +371,10 @@ assert@^1.1.1: dependencies: util "0.10.3" +assign-symbols@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/assign-symbols/-/assign-symbols-1.0.0.tgz#59667f41fadd4f20ccbc2bb96b8d4f7f78ec0367" + ast-types@0.10.1, ast-types@^0.10.1: version "0.10.1" resolved "https://registry.yarnpkg.com/ast-types/-/ast-types-0.10.1.tgz#f52fca9715579a14f841d67d7f8d25432ab6a3dd" @@ -790,8 +798,8 @@ caniuse-api@^1.5.2: lodash.uniq "^4.5.0" caniuse-db@^1.0.30000529, caniuse-db@^1.0.30000634, caniuse-db@^1.0.30000639: - version "1.0.30000783" - resolved "https://registry.yarnpkg.com/caniuse-db/-/caniuse-db-1.0.30000783.tgz#16b30d47266a4f515cc69ae0316b670c9603cdbe" + version "1.0.30000784" + resolved "https://registry.yarnpkg.com/caniuse-db/-/caniuse-db-1.0.30000784.tgz#1be95012d9489c7719074f81aee57dbdffe6361b" caseless@~0.11.0: version "0.11.0" @@ -970,8 +978,8 @@ code-point-at@^1.0.0: resolved "https://registry.yarnpkg.com/code-point-at/-/code-point-at-1.1.0.tgz#0d070b4d043a5bea33a2f1a40e2edb3d9a4ccf77" codemirror@^5.32.0: - version "5.32.0" - resolved "https://registry.yarnpkg.com/codemirror/-/codemirror-5.32.0.tgz#cb6ff5d8ef36d0b10f031130e2d9ebeee92c902e" + version "5.33.0" + resolved "https://registry.yarnpkg.com/codemirror/-/codemirror-5.33.0.tgz#462ad9a6fe8d38b541a9536a3997e1ef93b40c6a" collapse-white-space@^1.0.2: version "1.0.3" @@ -1136,8 +1144,8 @@ copy-descriptor@^0.1.0: resolved "https://registry.yarnpkg.com/copy-descriptor/-/copy-descriptor-0.1.1.tgz#676f6eb3c39997c2ee1ac3a924fd6124748f578d" copy-webpack-plugin@^4.3.0: - version "4.3.0" - resolved "https://registry.yarnpkg.com/copy-webpack-plugin/-/copy-webpack-plugin-4.3.0.tgz#cfdf4d131c78d66917a1bb863f86630497aacf42" + version "4.3.1" + resolved "https://registry.yarnpkg.com/copy-webpack-plugin/-/copy-webpack-plugin-4.3.1.tgz#19ba6370bf6f8e263cbd66185a2b79f2321a9302" dependencies: cacache "^10.0.1" find-cache-dir "^1.0.0" @@ -1583,9 +1591,15 @@ ee-first@1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d" +electron-releases@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/electron-releases/-/electron-releases-2.1.0.tgz#c5614bf811f176ce3c836e368a0625782341fd4e" + electron-to-chromium@^1.2.7: - version "1.3.28" - resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.28.tgz#8dd4e6458086644e9f9f0a1cf32e2a1f9dffd9ee" + version "1.3.30" + resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.30.tgz#9666f532a64586651fc56a72513692e820d06a80" + dependencies: + electron-releases "^2.1.0" elliptic@^6.0.0: version "6.4.0" @@ -1706,8 +1720,8 @@ es6-object-assign@~1.1.0: resolved "https://registry.yarnpkg.com/es6-object-assign/-/es6-object-assign-1.1.0.tgz#c2c3582656247c39ea107cb1e6652b6f9f24523c" es6-promise@^4.1.1: - version "4.1.1" - resolved "https://registry.yarnpkg.com/es6-promise/-/es6-promise-4.1.1.tgz#8811e90915d9a0dba36274f0b242dbda78f9c92a" + version "4.2.2" + resolved "https://registry.yarnpkg.com/es6-promise/-/es6-promise-4.2.2.tgz#f722d7769af88bd33bc13ec6605e1f92966b82d9" es6-set@~0.1.5: version "0.1.5" @@ -1906,9 +1920,10 @@ extend-shallow@^2.0.1: is-extendable "^0.1.0" extend-shallow@^3.0.0: - version "3.0.1" - resolved "https://registry.yarnpkg.com/extend-shallow/-/extend-shallow-3.0.1.tgz#4b6d8c49b147fee029dc9eb9484adb770f689844" + version "3.0.2" + resolved "https://registry.yarnpkg.com/extend-shallow/-/extend-shallow-3.0.2.tgz#26a71aaf073b39fb2127172746131c2704028db8" dependencies: + assign-symbols "^1.0.0" is-extendable "^1.0.1" extend@^3.0.0, extend@~3.0.0, extend@~3.0.1: @@ -1930,8 +1945,8 @@ extglob@^0.3.1: is-extglob "^1.0.0" extglob@^2.0.2: - version "2.0.2" - resolved "https://registry.yarnpkg.com/extglob/-/extglob-2.0.2.tgz#3290f46208db1b2e8eb8be0c94ed9e6ad80edbe2" + version "2.0.3" + resolved "https://registry.yarnpkg.com/extglob/-/extglob-2.0.3.tgz#55e019d0c95bf873949c737b7e5172dba84ebb29" dependencies: array-unique "^0.3.2" define-property "^1.0.0" @@ -2557,8 +2572,8 @@ html-entities@^1.2.0: resolved "https://registry.yarnpkg.com/html-entities/-/html-entities-1.2.1.tgz#0df29351f0721163515dfb9e5543e5f6eed5162f" html-minifier@^3.2.3: - version "3.5.7" - resolved "https://registry.yarnpkg.com/html-minifier/-/html-minifier-3.5.7.tgz#511e69bb5a8e7677d1012ebe03819aa02ca06208" + version "3.5.8" + resolved "https://registry.yarnpkg.com/html-minifier/-/html-minifier-3.5.8.tgz#5ccdb1f73a0d654e6090147511f6e6b2ee312700" dependencies: camel-case "3.0.x" clean-css "4.1.x" @@ -2567,7 +2582,7 @@ html-minifier@^3.2.3: ncname "1.0.x" param-case "2.1.x" relateurl "0.2.x" - uglify-js "3.2.x" + uglify-js "3.3.x" html-webpack-plugin@^2.30.1: version "2.30.1" @@ -2917,8 +2932,8 @@ is-in-browser@^1.1.3: resolved "https://registry.yarnpkg.com/is-in-browser/-/is-in-browser-1.1.3.tgz#56ff4db683a078c6082eb95dad7dc62e1d04f835" is-my-json-valid@^2.12.4: - version "2.17.0" - resolved "https://registry.yarnpkg.com/is-my-json-valid/-/is-my-json-valid-2.17.0.tgz#33f576b0dce46bf34328a7d471ef7ceab77a524d" + version "2.17.1" + resolved "https://registry.yarnpkg.com/is-my-json-valid/-/is-my-json-valid-2.17.1.tgz#3da98914a70a22f0a8563ef1511a246c6fc55471" dependencies: generate-function "^2.0.0" generate-object-property "^1.1.0" @@ -3426,8 +3441,8 @@ markdown-table@^1.1.0: resolved "https://registry.yarnpkg.com/markdown-table/-/markdown-table-1.1.1.tgz#4b3dd3a133d1518b8ef0dbc709bf2a1b4824bc8c" markdown-to-jsx@^6.2.2: - version "6.3.0" - resolved "https://registry.yarnpkg.com/markdown-to-jsx/-/markdown-to-jsx-6.3.0.tgz#5588bfeb6ed389a55869a3e225b014f495c9bf7f" + version "6.3.2" + resolved "https://registry.yarnpkg.com/markdown-to-jsx/-/markdown-to-jsx-6.3.2.tgz#f28236cc6e0441bb345811cb0c846629a1036fb7" dependencies: prop-types "^15.5.10" unquote "^1.1.0" @@ -5277,9 +5292,9 @@ rx-lite@*, rx-lite@^4.0.8: version "4.0.8" resolved "https://registry.yarnpkg.com/rx-lite/-/rx-lite-4.0.8.tgz#0b1e11af8bc44836f04a6407e92da42467b79444" -rxjs@5.5.5: - version "5.5.5" - resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-5.5.5.tgz#e164f11d38eaf29f56f08c3447f74ff02dd84e97" +rxjs@5.5.6: + version "5.5.6" + resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-5.5.6.tgz#e31fb96d6fd2ff1fd84bcea8ae9c02d007179c02" dependencies: symbol-observable "1.0.1" @@ -5991,8 +6006,8 @@ tslint@5.8.0: tsutils "^2.12.1" tsutils@^2.12.1, tsutils@^2.8.0: - version "2.13.1" - resolved "https://registry.yarnpkg.com/tsutils/-/tsutils-2.13.1.tgz#d6d1cc0f7c04cf9fb3b28a292973cffb9cfbe09a" + version "2.14.0" + resolved "https://registry.yarnpkg.com/tsutils/-/tsutils-2.14.0.tgz#bc5291622aa2448c1baffc544bcc14ecfa528fb7" dependencies: tslib "^1.8.0" @@ -6035,6 +6050,10 @@ typedarray@^0.0.6: version "0.0.6" resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777" +typesafe-actions@1.0.0-rc.1: + version "1.0.0-rc.1" + resolved "https://registry.yarnpkg.com/typesafe-actions/-/typesafe-actions-1.0.0-rc.1.tgz#170dd8501f6a444cfc0d286960c4693fe13410a4" + typescript@2.7.0-dev.20171216: version "2.7.0-dev.20171216" resolved "https://registry.yarnpkg.com/typescript/-/typescript-2.7.0-dev.20171216.tgz#ae3ff4ea462a8a5f37923108edeedfcaf9c5f1b7" @@ -6047,16 +6066,23 @@ ua-parser-js@^0.7.9: version "0.7.17" resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.17.tgz#e9ec5f9498b9ec910e7ae3ac626a805c4d09ecac" -uglify-es@^3.2.1: +uglify-es@3.2.2: version "3.2.2" resolved "https://registry.yarnpkg.com/uglify-es/-/uglify-es-3.2.2.tgz#15c62b7775002c81b7987a1c49ecd3f126cace73" dependencies: commander "~2.12.1" source-map "~0.6.1" -uglify-js@3.2.x: - version "3.2.2" - resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-3.2.2.tgz#870e4b34ed733d179284f9998efd3293f7fd73f6" +uglify-es@^3.2.1: + version "3.3.2" + resolved "https://registry.yarnpkg.com/uglify-es/-/uglify-es-3.3.2.tgz#2401df8be2a433314523753f28810793a40c5462" + dependencies: + commander "~2.12.1" + source-map "~0.6.1" + +uglify-js@3.3.x: + version "3.3.2" + resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-3.3.2.tgz#517af20aad7abe15e1e4c9aa33c0cc72aa0107ab" dependencies: commander "~2.12.1" source-map "~0.6.1" @@ -6074,7 +6100,7 @@ uglify-to-browserify@~1.0.0: version "1.0.2" resolved "https://registry.yarnpkg.com/uglify-to-browserify/-/uglify-to-browserify-1.0.2.tgz#6e0924d6bda6b5afe349e39a6d632850a0f882b7" -uglifyjs-webpack-plugin@1.1.4, uglifyjs-webpack-plugin@^1.0.1: +uglifyjs-webpack-plugin@1.1.4: version "1.1.4" resolved "https://registry.yarnpkg.com/uglifyjs-webpack-plugin/-/uglifyjs-webpack-plugin-1.1.4.tgz#e43ad6e736c315024eb99481a7cc9362d6a066be" dependencies: @@ -6095,6 +6121,19 @@ uglifyjs-webpack-plugin@^0.4.6: uglify-js "^2.8.29" webpack-sources "^1.0.1" +uglifyjs-webpack-plugin@^1.0.1: + version "1.1.5" + resolved "https://registry.yarnpkg.com/uglifyjs-webpack-plugin/-/uglifyjs-webpack-plugin-1.1.5.tgz#5ec4a16da0fd10c96538f715caed10dbdb180875" + dependencies: + cacache "^10.0.0" + find-cache-dir "^1.0.0" + schema-utils "^0.3.0" + serialize-javascript "^1.4.0" + source-map "^0.6.1" + uglify-es "3.2.2" + webpack-sources "^1.0.1" + worker-farm "^1.4.1" + uid-number@^0.0.6: version "0.0.6" resolved "https://registry.yarnpkg.com/uid-number/-/uid-number-0.0.6.tgz#0ea10e8035e8eb5b8e4449f06da1c730663baa81"