diff --git a/index.d.ts b/index.d.ts index db64dd5e3c..aa8ec2ad31 100644 --- a/index.d.ts +++ b/index.d.ts @@ -247,6 +247,17 @@ export type Observer = { next?(value: T): void } +/** + * Extend the state + * + * This is used by store enhancers and store creators to extend state. + * If there is no state extension, it just returns the state, as is, otherwise + * it returns the state joined with its extension. + */ +export type ExtendState = [Extension] extends [never] + ? State + : State & Extension + /** * A store is an object that holds the application's state tree. * There should only be a single store in a Redux app, as the composition @@ -254,8 +265,15 @@ export type Observer = { * * @template S The type of state held by this store. * @template A the type of actions which may be dispatched by this store. + * @template StateExt any extension to state from store enhancers + * @template Ext any extensions to the store from store enhancers */ -export interface Store { +export interface Store< + S = any, + A extends Action = AnyAction, + StateExt = never, + Ext = {} +> { /** * Dispatches an action. It is the only way to trigger a state change. * @@ -326,9 +344,9 @@ export interface Store { * * @param nextReducer The reducer for the store to use instead. */ - replaceReducer( + replaceReducer( nextReducer: Reducer - ): Store + ): Store, NewActions, StateExt, Ext> & Ext /** * Interoperability point for observable/reactive libraries. @@ -355,15 +373,15 @@ export type DeepPartial = { * @template StateExt State extension that is mixed into the state type. */ export interface StoreCreator { - ( + ( reducer: Reducer, enhancer?: StoreEnhancer - ): Store & Ext - ( + ): Store, A, StateExt, Ext> & Ext + ( reducer: Reducer, preloadedState?: PreloadedState, enhancer?: StoreEnhancer - ): Store & Ext + ): Store, A, StateExt, Ext> & Ext } /** @@ -417,16 +435,17 @@ export const createStore: StoreCreator * @template Ext Store extension that is mixed into the Store type. * @template StateExt State extension that is mixed into the state type. */ -export type StoreEnhancer = ( - next: StoreEnhancerStoreCreator +export type StoreEnhancer = ( + next: StoreEnhancerStoreCreator ) => StoreEnhancerStoreCreator -export type StoreEnhancerStoreCreator = < + +export type StoreEnhancerStoreCreator = < S = any, A extends Action = AnyAction >( reducer: Reducer, preloadedState?: PreloadedState -) => Store & Ext +) => Store, A, StateExt, Ext> & Ext /* middleware */ diff --git a/src/applyMiddleware.ts b/src/applyMiddleware.ts index fe8edefa11..7bddf3fc05 100644 --- a/src/applyMiddleware.ts +++ b/src/applyMiddleware.ts @@ -51,7 +51,7 @@ export default function applyMiddleware( ): StoreEnhancer<{ dispatch: Ext }> export default function applyMiddleware( ...middlewares: Middleware[] -): StoreEnhancer { +): StoreEnhancer { return (createStore: StoreCreator) => ( reducer: Reducer, ...args: any[] diff --git a/src/createStore.ts b/src/createStore.ts index f6ab9e2cf2..0a0d35b835 100644 --- a/src/createStore.ts +++ b/src/createStore.ts @@ -5,7 +5,8 @@ import { PreloadedState, StoreEnhancer, Dispatch, - Observer + Observer, + ExtendState } from './types/store' import { Action } from './types/actions' import { Reducer } from './types/reducers' @@ -37,20 +38,35 @@ import isPlainObject from './utils/isPlainObject' * @returns {Store} A Redux store that lets you read the state, dispatch actions * and subscribe to changes. */ -export default function createStore( +export default function createStore< + S, + A extends Action, + Ext = {}, + StateExt = never +>( reducer: Reducer, enhancer?: StoreEnhancer -): Store & Ext -export default function createStore( +): Store, A, StateExt, Ext> & Ext +export default function createStore< + S, + A extends Action, + Ext = {}, + StateExt = never +>( reducer: Reducer, preloadedState?: PreloadedState, - enhancer?: StoreEnhancer -): Store & Ext -export default function createStore( + enhancer?: StoreEnhancer +): Store, A, StateExt, Ext> & Ext +export default function createStore< + S, + A extends Action, + Ext = {}, + StateExt = never +>( reducer: Reducer, preloadedState?: PreloadedState | StoreEnhancer, - enhancer?: StoreEnhancer -): Store & Ext { + enhancer?: StoreEnhancer +): Store, A, StateExt, Ext> & Ext { if ( (typeof preloadedState === 'function' && typeof enhancer === 'function') || (typeof enhancer === 'function' && typeof arguments[3] === 'function') @@ -74,7 +90,7 @@ export default function createStore( return enhancer(createStore)(reducer, preloadedState as PreloadedState< S - >) as Store & Ext + >) as Store, A, StateExt, Ext> & Ext } if (typeof reducer !== 'function') { @@ -252,7 +268,7 @@ export default function createStore( */ function replaceReducer( nextReducer: Reducer - ): Store { + ): Store, NewActions, StateExt, Ext> & Ext { if (typeof nextReducer !== 'function') { throw new Error('Expected the nextReducer to be a function.') } @@ -269,7 +285,13 @@ export default function createStore( // the new state tree with any relevant data from the old one. dispatch({ type: ActionTypes.REPLACE } as A) // change the type of the store by casting it to the new store - return (store as unknown) as Store + return (store as unknown) as Store< + ExtendState, + NewActions, + StateExt, + Ext + > & + Ext } /** @@ -317,12 +339,12 @@ export default function createStore( // the initial state tree. dispatch({ type: ActionTypes.INIT } as A) - const store: Store & Ext = ({ + const store = ({ dispatch: dispatch as Dispatch, subscribe, getState, replaceReducer, [$$observable]: observable - } as unknown) as Store & Ext + } as unknown) as Store, A, StateExt, Ext> & Ext return store } diff --git a/src/index.ts b/src/index.ts index 350d1f61eb..40df5f346d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -19,7 +19,8 @@ export { Store, StoreCreator, StoreEnhancer, - StoreEnhancerStoreCreator + StoreEnhancerStoreCreator, + ExtendState } from './types/store' // reducers export { diff --git a/src/types/store.ts b/src/types/store.ts index 688b46b6c9..aaebb0ad21 100644 --- a/src/types/store.ts +++ b/src/types/store.ts @@ -2,6 +2,20 @@ import { Action, AnyAction } from './actions' import { Reducer } from './reducers' +/** + * Extend the state + * + * This is used by store enhancers and store creators to extend state. + * If there is no state extension, it just returns the state, as is, otherwise + * it returns the state joined with its extension. + * + * Reference for future devs: + * https://github.com/microsoft/TypeScript/issues/31751#issuecomment-498526919 + */ +export type ExtendState = [Extension] extends [never] + ? State + : State & Extension + /** * Internal "virtual" symbol used to make the `CombinedState` type unique. */ @@ -107,8 +121,15 @@ export type Observer = { * * @template S The type of state held by this store. * @template A the type of actions which may be dispatched by this store. + * @template StateExt any extension to state from store enhancers + * @template Ext any extensions to the store from store enhancers */ -export interface Store { +export interface Store< + S = any, + A extends Action = AnyAction, + StateExt = never, + Ext = {} +> { /** * Dispatches an action. It is the only way to trigger a state change. * @@ -179,9 +200,9 @@ export interface Store { * * @param nextReducer The reducer for the store to use instead. */ - replaceReducer( + replaceReducer( nextReducer: Reducer - ): Store + ): Store, NewActions, StateExt, Ext> & Ext /** * Interoperability point for observable/reactive libraries. @@ -204,15 +225,15 @@ export interface Store { * @template StateExt State extension that is mixed into the state type. */ export interface StoreCreator { - ( + ( reducer: Reducer, enhancer?: StoreEnhancer - ): Store & Ext - ( + ): Store, A, StateExt, Ext> & Ext + ( reducer: Reducer, preloadedState?: PreloadedState, enhancer?: StoreEnhancer - ): Store & Ext + ): Store, A, StateExt, Ext> & Ext } /** @@ -236,13 +257,13 @@ export interface StoreCreator { * @template Ext Store extension that is mixed into the Store type. * @template StateExt State extension that is mixed into the state type. */ -export type StoreEnhancer = ( - next: StoreEnhancerStoreCreator +export type StoreEnhancer = ( + next: StoreEnhancerStoreCreator ) => StoreEnhancerStoreCreator -export type StoreEnhancerStoreCreator = < +export type StoreEnhancerStoreCreator = < S = any, A extends Action = AnyAction >( reducer: Reducer, preloadedState?: PreloadedState -) => Store & Ext +) => Store, A, StateExt, Ext> & Ext diff --git a/test/typescript/enhancers.ts b/test/typescript/enhancers.ts index 5e8e180bd9..e39ca83628 100644 --- a/test/typescript/enhancers.ts +++ b/test/typescript/enhancers.ts @@ -18,7 +18,25 @@ const reducer: Reducer = null as any function dispatchExtension() { type PromiseDispatch = (promise: Promise) => Promise - const enhancer: StoreEnhancer<{ dispatch: PromiseDispatch }> = null as any + const enhancer: StoreEnhancer<{ + dispatch: PromiseDispatch + }> = createStore => ( + reducer: Reducer, + preloadedState?: any + ) => { + const store = createStore(reducer, preloadedState) + return { + ...store, + dispatch: (action: any) => { + if (action.type) { + store.dispatch(action) + } else if (action.then) { + action.then(store.dispatch) + } + return action + } + } + } const store = createStore(reducer, enhancer) @@ -43,10 +61,21 @@ function stateExtension() { A extends Action = AnyAction >( reducer: Reducer, - preloadedState?: PreloadedState + preloadedState?: any ) => { - const wrappedReducer: Reducer = null as any - const wrappedPreloadedState: PreloadedState = null as any + const wrappedReducer: Reducer = (state, action) => { + const newState = reducer(state, action) + return { + ...newState, + extraField: 'extra' + } + } + const wrappedPreloadedState = preloadedState + ? { + ...preloadedState, + extraField: 'extra' + } + : undefined return createStore(wrappedReducer, wrappedPreloadedState) } @@ -71,3 +100,198 @@ function extraMethods() { // typings:expect-error store.wrongMethod() } + +/** + * replaceReducer with a store enhancer + */ +function replaceReducerExtender() { + interface ExtraState { + extraField: 'extra' + } + + const enhancer: StoreEnhancer< + { method(): string }, + ExtraState + > = createStore => ( + reducer: Reducer, + preloadedState?: any + ) => { + const wrappedReducer: Reducer = (state, action) => { + const newState = reducer(state, action) + return { + ...newState, + extraField: 'extra' + } + } + const wrappedPreloadedState = preloadedState + ? { + ...preloadedState, + extraField: 'extra' + } + : undefined + return createStore(wrappedReducer, wrappedPreloadedState) + } + + const store = createStore(reducer, enhancer) + + const newReducer = ( + state: { test: boolean } = { test: true }, + _: AnyAction + ) => state + + const newStore = store.replaceReducer(newReducer) + newStore.getState().test + newStore.getState().extraField + // typings:expect-error + newStore.getState().wrongField + + const res: string = newStore.method() + // typings:expect-error + newStore.wrongMethod() +} + +function mhelmersonExample() { + interface State { + someField: 'string' + } + + interface ExtraState { + extraField: 'extra' + } + + const reducer: Reducer = null as any + + function stateExtensionExpectedToWork() { + interface ExtraState { + extraField: 'extra' + } + + const enhancer: StoreEnhancer<{}, ExtraState> = createStore => < + S, + A extends Action = AnyAction + >( + reducer: Reducer, + preloadedState?: any + ) => { + const wrappedReducer: Reducer = (state, action) => { + const newState = reducer(state, action) + return { + ...newState, + extraField: 'extra' + } + } + const wrappedPreloadedState = preloadedState + ? { + ...preloadedState, + extraField: 'extra' + } + : undefined + const store = createStore(wrappedReducer, wrappedPreloadedState) + return { + ...store, + replaceReducer( + nextReducer: ( + state: NS & ExtraState | undefined, + action: NA + ) => NS & ExtraState + ) { + const nextWrappedReducer: Reducer = ( + state, + action + ) => { + const newState = nextReducer(state, action) + return { + ...newState, + extraField: 'extra' + } + } + return store.replaceReducer(nextWrappedReducer) + } + } + } + + const store = createStore(reducer, enhancer) + store.replaceReducer(reducer) + + store.getState().extraField + // typings:expect-error + store.getState().wrongField + // typings:expect-error + store.getState().test + + const newReducer = ( + state: { test: boolean } = { test: true }, + _: AnyAction + ) => state + + const newStore = store.replaceReducer(newReducer) + newStore.getState().test + newStore.getState().extraField + // typings:expect-error + newStore.getState().wrongField + } +} + +function finalHelmersonExample() { + interface ExtraState { + foo: string + } + + function persistReducer( + config: any, + reducer: Reducer + ) { + return (state: S & ExtraState | undefined, action: AnyAction) => { + const newState = reducer(state, (action as unknown) as A) + return { + ...newState, + foo: 'hi' + } + } + } + + function persistStore(store: S) { + return store + } + + function createPersistEnhancer( + persistConfig: any + ): StoreEnhancer<{}, ExtraState> { + return createStore => ( + reducer: Reducer, + preloadedState?: any + ) => { + const persistedReducer = persistReducer(persistConfig, reducer) + const store = createStore(persistedReducer, preloadedState) + const persistor = persistStore(store) + + return { + ...store, + replaceReducer: nextReducer => { + return store.replaceReducer( + persistReducer(persistConfig, nextReducer) + ) + }, + persistor + } + } + } + + const store = createStore(reducer, createPersistEnhancer('hi')) + + store.getState().foo + // typings:expect-error + store.getState().wrongField + + const newReducer = ( + state: { test: boolean } = { test: true }, + _: AnyAction + ) => state + + const newStore = store.replaceReducer(newReducer) + newStore.getState().test + // typings:expect-error + newStore.getState().whatever + // typings:expect-error + newStore.getState().wrongField +} diff --git a/test/typescript/store.ts b/test/typescript/store.ts index 123464ce55..b387c0b15c 100644 --- a/test/typescript/store.ts +++ b/test/typescript/store.ts @@ -7,7 +7,8 @@ import { StoreCreator, StoreEnhancerStoreCreator, Unsubscribe, - Observer + Observer, + ExtendState } from 'redux' import 'symbol-observable' @@ -19,6 +20,41 @@ type State = { } } +/* extended state */ +const noExtend: ExtendState = { + a: 'a', + b: { + c: 'c', + d: 'd' + } +} +// typings:expect-error +const noExtendError: ExtendState = { + a: 'a', + b: { + c: 'c', + d: 'd' + }, + e: 'oops' +} + +const yesExtend: ExtendState = { + a: 'a', + b: { + c: 'c', + d: 'd' + }, + yes: 'we can' +} +// typings:expect-error +const yesExtendError: ExtendState = { + a: 'a', + b: { + c: 'c', + d: 'd' + } +} + interface DerivedAction extends Action { type: 'a' b: 'b' @@ -56,6 +92,9 @@ const funcWithStore = (store: Store) => {} const store: Store = createStore(reducer) +// ensure that an array-based state works +const arrayReducer = (state: any[] = []) => state || [] +const storeWithArrayState: Store = createStore(arrayReducer) const storeWithPreloadedState: Store = createStore(reducer, { a: 'a', b: { c: 'c', d: 'd' }