diff --git a/docs/usage/usage-with-typescript.md b/docs/usage/usage-with-typescript.md index f145663ba7..76682e9120 100644 --- a/docs/usage/usage-with-typescript.md +++ b/docs/usage/usage-with-typescript.md @@ -42,24 +42,42 @@ const store = configureStore({ export type AppDispatch = typeof store.dispatch ``` -### Extending the `Dispatch` type +### Correct typings for the `Dispatch` type -By default, this `AppDispatch` type will account only for the already included `redux-thunk` middleware. If you're adding additional middlewares that provide different return types for some actions, you can overload that `AppDispatch` type. While you can just extend the `AppDispatch` type, it's recommended to do so by adding additional type overloads for `dispatch` on the store, to keep everything more consistent: +The type of the `dispatch` function type will be directly inferred from the `middleware` option. So if you add _correctly typed_ middlewares, `dispatch` should already be correctly typed. -```typescript -const _store = configureStore({ - /* ... */ -}) +There might however be cases, where TypeScript decides to simplify your provided middleware array down to just `Array`. In that case, you have to either specify the array type manually as a tuple, or in TS versions >= 3.4, just add `as const` to your definition. -type EnhancedStoreType = { - dispatch(action: MyCustomActionType): MyCustomReturnType - dispatch(action: MyCustomActionType2): MyCustomReturnType2 -} & typeof _store +Please note that when calling `getDefaultMiddleware` in TypeScript, you have to provide the state type as a generic argument. -export const store: EnhancedStoreType = _store -export type AppDispatch = typeof store.dispatch +```ts +import { configureStore } from '@reduxjs/toolkit' +import additionalMiddleware from 'additional-middleware' +// @ts-ignore +import untypedMiddleware from 'untyped-middleware' +import rootReducer from './rootReducer' + +type RootState = ReturnType +const store = configureStore({ + reducer: rootReducer, + middleware: [ + // getDefaultMiddleware needs to be called with the state type + ...getDefaultMiddleware(), + // correctly typed middlewares can just be used + additionalMiddleware, + // you can also manually type middlewares manually + untypedMiddleware as Middleware< + (action: Action<'specialAction'>) => number, + RootState + > + ] as const // prevent this from becoming just `Array` +}) + +type AppDispatch = typeof store.dispatch ``` +If you need any additional reference or examples, [the type tests for `configureStore`](https://github.com/reduxjs/redux-toolkit/blob/master/type-tests/files/configureStore.typetest.ts) contain many different scenarios on how to type this. + ### Using the extracted `Dispatch` type with React-Redux By default, the React-Redux `useDispatch` hook does not contain any types that take middlewares into account. If you need a more specific type for the `dispatch` function when dispatching, you may specify the type of the returned `dispatch` function, or create a custom-typed version of `useSelector`. See [the React-Redux documentation](https://react-redux.js.org/using-react-redux/static-typing#typing-the-usedispatch-hook) for details. diff --git a/etc/redux-toolkit.api.md b/etc/redux-toolkit.api.md index e70f65981f..b3a419e5dc 100644 --- a/etc/redux-toolkit.api.md +++ b/etc/redux-toolkit.api.md @@ -9,6 +9,7 @@ import { AnyAction } from 'redux'; import { default as createNextState } from 'immer'; import { createSelector } from 'reselect'; import { DeepPartial } from 'redux'; +import { Dispatch } from 'redux'; import { Draft } from 'immer'; import { EnhancerOptions } from 'redux-devtools-extension'; import { Middleware } from 'redux'; @@ -17,7 +18,7 @@ import { ReducersMapObject } from 'redux'; import { Store } from 'redux'; import { StoreEnhancer } from 'redux'; import { ThunkAction } from 'redux-thunk'; -import { ThunkDispatch } from 'redux-thunk'; +import { ThunkMiddleware } from 'redux-thunk'; // @public export interface ActionCreatorWithNonInferrablePayload extends BaseActionCreator { @@ -80,13 +81,13 @@ export type CaseReducerWithPrepare = { export type ConfigureEnhancersCallback = (defaultEnhancers: StoreEnhancer[]) => StoreEnhancer[]; // @public -export function configureStore(options: ConfigureStoreOptions): EnhancedStore; +export function configureStore = [ThunkMiddleware]>(options: ConfigureStoreOptions): EnhancedStore; // @public -export interface ConfigureStoreOptions { +export interface ConfigureStoreOptions = Middlewares> { devTools?: boolean | EnhancerOptions; enhancers?: StoreEnhancer[] | ConfigureEnhancersCallback; - middleware?: Middleware<{}, S>[]; + middleware?: M; preloadedState?: DeepPartial; reducer: Reducer | ReducersMapObject; } @@ -124,16 +125,19 @@ export interface CreateSliceOptions extends Store { - // (undocumented) - dispatch: ThunkDispatch; +export interface EnhancedStore = Middlewares> extends Store { + dispatch: DispatchForMiddlewares & Dispatch; } // @public (undocumented) export function findNonSerializableValue(value: unknown, path?: ReadonlyArray, isSerializable?: (value: unknown) => boolean, getEntries?: (value: unknown) => [string, any][]): NonSerializableValue | false; // @public -export function getDefaultMiddleware(options?: GetDefaultMiddlewareOptions): Middleware<{}, S>[]; +export function getDefaultMiddleware = { + thunk: true; + immutableCheck: true; + serializableCheck: true; +}>(options?: O): Array | ThunkMiddlewareFor>; // @public export function getType(actionCreator: PayloadActionCreator): T; diff --git a/src/configureStore.ts b/src/configureStore.ts index 560159e905..8a4468d43a 100644 --- a/src/configureStore.ts +++ b/src/configureStore.ts @@ -10,16 +10,18 @@ import { AnyAction, StoreEnhancer, Store, - DeepPartial + DeepPartial, + Dispatch } from 'redux' import { composeWithDevTools, EnhancerOptions as DevToolsOptions } from 'redux-devtools-extension' -import { ThunkDispatch } from 'redux-thunk' +import { ThunkMiddleware } from 'redux-thunk' import isPlainObject from './isPlainObject' import { getDefaultMiddleware } from './getDefaultMiddleware' +import { DispatchForMiddlewares } from './tsHelpers' const IS_PRODUCTION = process.env.NODE_ENV === 'production' @@ -37,7 +39,11 @@ export type ConfigureEnhancersCallback = ( * * @public */ -export interface ConfigureStoreOptions { +export interface ConfigureStoreOptions< + S = any, + A extends Action = AnyAction, + M extends Middlewares = Middlewares +> { /** * A single reducer function that will be used as the root reducer, or an * object of slice reducers that will be passed to `combineReducers()`. @@ -48,7 +54,7 @@ export interface ConfigureStoreOptions { * An array of Redux middleware to install. If not supplied, defaults to * the set of middleware returned by `getDefaultMiddleware()`. */ - middleware?: Middleware<{}, S>[] + middleware?: M /** * Whether to enable Redux DevTools integration. Defaults to `true`. @@ -82,18 +88,25 @@ export interface ConfigureStoreOptions { enhancers?: StoreEnhancer[] | ConfigureEnhancersCallback } +type Middlewares = ReadonlyArray> + /** * A Redux store returned by `configureStore()`. Supports dispatching * side-effectful _thunks_ in addition to plain actions. * * @public */ -export interface EnhancedStore - extends Store { +export interface EnhancedStore< + S = any, + A extends Action = AnyAction, + M extends Middlewares = Middlewares +> extends Store { /** + * The `dispatch` method of your store, enhanced by all it's middlewares. + * * @inheritdoc */ - dispatch: ThunkDispatch + dispatch: DispatchForMiddlewares & Dispatch } /** @@ -104,9 +117,11 @@ export interface EnhancedStore * * @public */ -export function configureStore( - options: ConfigureStoreOptions -): EnhancedStore { +export function configureStore< + S = any, + A extends Action = AnyAction, + M extends Middlewares = [ThunkMiddleware] +>(options: ConfigureStoreOptions): EnhancedStore { const { reducer = undefined, middleware = getDefaultMiddleware(), @@ -147,7 +162,7 @@ export function configureStore( storeEnhancers = enhancers(storeEnhancers) } - const composedEnhancer = finalCompose(...storeEnhancers) as StoreEnhancer + const composedEnhancer = finalCompose(...storeEnhancers) as any return createStore( rootReducer, diff --git a/src/getDefaultMiddleware.test.ts b/src/getDefaultMiddleware.test.ts index d56e9156f5..6791d19146 100644 --- a/src/getDefaultMiddleware.test.ts +++ b/src/getDefaultMiddleware.test.ts @@ -1,4 +1,4 @@ -import { createStore, applyMiddleware, AnyAction } from 'redux' +import { AnyAction } from 'redux' import { getDefaultMiddleware } from './getDefaultMiddleware' import { configureStore } from './configureStore' import thunk, { ThunkAction } from 'redux-thunk' diff --git a/src/getDefaultMiddleware.ts b/src/getDefaultMiddleware.ts index 9bbaa14636..ab395818a7 100644 --- a/src/getDefaultMiddleware.ts +++ b/src/getDefaultMiddleware.ts @@ -1,5 +1,5 @@ -import { Middleware } from 'redux' -import thunkMiddleware from 'redux-thunk' +import { Middleware, AnyAction } from 'redux' +import thunkMiddleware, { ThunkMiddleware } from 'redux-thunk' /* PROD_START_REMOVE_UMD */ import createImmutableStateInvariantMiddleware from 'redux-immutable-state-invariant' /* PROD_STOP_REMOVE_UMD */ @@ -28,6 +28,14 @@ interface GetDefaultMiddlewareOptions { serializableCheck?: boolean | SerializableStateInvariantMiddlewareOptions } +type ThunkMiddlewareFor = O extends { + thunk: false +} + ? never + : O extends { thunk: { extraArgument: infer E } } + ? ThunkMiddleware + : ThunkMiddleware + /** * Returns any array containing the default middleware installed by * `configureStore()`. Useful if you want to configure your store with a custom @@ -37,9 +45,14 @@ interface GetDefaultMiddlewareOptions { * * @public */ -export function getDefaultMiddleware( - options: GetDefaultMiddlewareOptions = {} -): Middleware<{}, S>[] { +export function getDefaultMiddleware< + S = any, + O extends Partial = { + thunk: true + immutableCheck: true + serializableCheck: true + } +>(options: O = {} as O): Array | ThunkMiddlewareFor> { const { thunk = true, immutableCheck = true, @@ -86,5 +99,5 @@ export function getDefaultMiddleware( } } - return middlewareArray + return middlewareArray as any } diff --git a/src/tsHelpers.ts b/src/tsHelpers.ts index c31c5b0773..bbde02b85b 100644 --- a/src/tsHelpers.ts +++ b/src/tsHelpers.ts @@ -1,3 +1,5 @@ +import { Middleware } from 'redux' + /** * return True if T is `any`, otherwise return False * taken from https://github.com/joonhocho/tsdef @@ -60,3 +62,28 @@ export type IsUnknownOrNonInferrable = AtLeastTS35< IsUnknown, IsEmptyObj> > + +/** + * Combines all dispatch signatures of all middlewares in the array `M` into + * one intersected dispatch signature. + */ +export type DispatchForMiddlewares = M extends ReadonlyArray + ? UnionToIntersection< + M[number] extends infer MiddlewareValues + ? MiddlewareValues extends Middleware + ? DispatchExt extends Function + ? DispatchExt + : never + : never + : never + > + : never + +/** + * Convert a Union type `(A|B)` to and intersecion type `(A&B)` + */ +type UnionToIntersection = (U extends any + ? (k: U) => void + : never) extends ((k: infer I) => void) + ? I + : never diff --git a/type-tests/files/configureStore.typetest.ts b/type-tests/files/configureStore.typetest.ts index 4635019d22..95509a361b 100644 --- a/type-tests/files/configureStore.typetest.ts +++ b/type-tests/files/configureStore.typetest.ts @@ -6,7 +6,8 @@ import { Reducer, Store } from 'redux' -import { configureStore, PayloadAction } from '../../src' +import { configureStore, PayloadAction, getDefaultMiddleware } from 'src' +import thunk, { ThunkMiddleware, ThunkAction, ThunkDispatch } from 'redux-thunk' /* * Test: configureStore() requires a valid reducer or reducer map. @@ -162,20 +163,95 @@ import { configureStore, PayloadAction } from '../../src' } /** - * Test: Returned store allows dispatching thunks. + * Test: Dispatch typings */ { - const store = configureStore({ - reducer: () => 0 - }) + type StateA = number + const reducerA = () => 0 + function thunkA() { + return ((() => {}) as any) as ThunkAction, StateA, any, any> + } - function incrementMaybe() { - return (dispatch: Dispatch) => { - if (Math.random() > 0.5) { - dispatch({ type: 'increment' }) - } - } + type StateB = string + function thunkB() { + return (dispatch: Dispatch, getState: () => StateB) => {} } + /** + * Test: by default, dispatching Thunks is possible + */ + { + const store = configureStore({ + reducer: reducerA + }) - store.dispatch(incrementMaybe()) + store.dispatch(thunkA()) + // typings:expect-error + store.dispatch(thunkB()) + } + /** + * Test: removing the Thunk Middleware + */ + { + const store = configureStore({ + reducer: reducerA, + middleware: [] + }) + // typings:expect-error + store.dispatch(thunkA()) + // typings:expect-error + store.dispatch(thunkB()) + } + /** + * Test: adding the thunk middleware by hand + */ + { + const store = configureStore({ + reducer: reducerA, + middleware: [thunk] as [ThunkMiddleware] + }) + store.dispatch(thunkA()) + // typings:expect-error + store.dispatch(thunkB()) + } + /** + * Test: using getDefaultMiddleware + */ + { + const store = configureStore({ + reducer: reducerA, + middleware: getDefaultMiddleware() + }) + + store.dispatch(thunkA()) + // typings:expect-error + store.dispatch(thunkB()) + } + /** + * Test: custom middleware + */ + { + const store = configureStore({ + reducer: reducerA, + middleware: ([] as any) as [Middleware<(a: StateA) => boolean, StateA>] + }) + const result: boolean = store.dispatch(5) + // typings:expect-error + const result2: string = store.dispatch(5) + } + /** + * Test: multiple custom middleware + */ + { + const store = configureStore({ + reducer: reducerA, + middleware: ([] as any) as [ + Middleware<(a: 'a') => 'A', StateA>, + Middleware<(b: 'b') => 'B', StateA>, + ThunkMiddleware + ] + }) + const result: 'A' = store.dispatch('a') + const result2: 'B' = store.dispatch('b') + const result3: Promise<'A'> = store.dispatch(thunkA()) + } } diff --git a/type-tests/files/min-3.4/configureStore.typetest.ts b/type-tests/files/min-3.4/configureStore.typetest.ts new file mode 100644 index 0000000000..4a73994780 --- /dev/null +++ b/type-tests/files/min-3.4/configureStore.typetest.ts @@ -0,0 +1,36 @@ +import { Dispatch, Middleware } from 'redux' +import { configureStore, getDefaultMiddleware } from 'src' +import { ThunkAction } from 'redux-thunk' + +/** + * Test: Dispatch typings + */ +{ + type StateA = number + const reducerA = () => 0 + function thunkA() { + return ((() => {}) as any) as ThunkAction, StateA, any, any> + } + + type StateB = string + function thunkB() { + return (dispatch: Dispatch, getState: () => StateB) => {} + } + + /** + * Test: custom middleware and getDefaultMiddleware + */ + { + const store = configureStore({ + reducer: reducerA, + middleware: [ + ((() => {}) as any) as Middleware<(a: 'a') => 'A', StateA>, + ...getDefaultMiddleware() + ] as const + }) + const result1: 'A' = store.dispatch('a') + const result2: Promise<'A'> = store.dispatch(thunkA()) + // typings:expect-error + store.dispatch(thunkB()) + } +} diff --git a/type-tests/lib.ts b/type-tests/lib.ts new file mode 100644 index 0000000000..d8c37341a5 --- /dev/null +++ b/type-tests/lib.ts @@ -0,0 +1,17 @@ +import { check } from 'typings-tester' +import { versionMajorMinor, sys, findConfigFile } from 'typescript' + +export const tsVersion = Number.parseFloat(versionMajorMinor) + +export const testIf = (condition: boolean) => (condition ? test : test.skip) + +export function checkDirectory(path: string, bail: boolean = false, depth = 1) { + const files = sys.readDirectory(path, ['.ts', '.tsx'], [], [], depth) + const tsConfigPath = findConfigFile(path, sys.fileExists) + + if (!tsConfigPath) { + throw new Error(`Cannot find TypeScript config file in ${path}.`) + } + + check(files, tsConfigPath, bail) +} diff --git a/type-tests/types-min-3.4.test.ts b/type-tests/types-min-3.4.test.ts new file mode 100644 index 0000000000..b4c04c6b6b --- /dev/null +++ b/type-tests/types-min-3.4.test.ts @@ -0,0 +1,5 @@ +import { testIf, checkDirectory, tsVersion } from './lib' + +testIf(tsVersion >= 3.4)('Types >= 3.4', () => { + checkDirectory(`${__dirname}/files/min-3.4`) +}) diff --git a/type-tests/types.test.ts b/type-tests/types.test.ts index e959536c50..846d683cf6 100644 --- a/type-tests/types.test.ts +++ b/type-tests/types.test.ts @@ -1,4 +1,4 @@ -import { checkDirectory } from 'typings-tester' +import { checkDirectory } from './lib' test('Types', () => { checkDirectory(`${__dirname}/files`)