diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 5cc462e99f..b6180d8874 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -96,7 +96,7 @@ jobs: fail-fast: false matrix: node: ['14.x'] - ts: ['3.9', '4.0', '4.1', '4.2', '4.3', '4.4', '4.5', 'next'] + ts: ['4.1', '4.2', '4.3', '4.4', '4.5', 'next'] steps: - name: Checkout repo uses: actions/checkout@v2 diff --git a/packages/action-listener-middleware/src/tests/fork.test.ts b/packages/action-listener-middleware/src/tests/fork.test.ts index af4fd452a7..47c5b60d19 100644 --- a/packages/action-listener-middleware/src/tests/fork.test.ts +++ b/packages/action-listener-middleware/src/tests/fork.test.ts @@ -52,8 +52,11 @@ describe('fork', () => { }, }) const { increment, decrement, incrementByAmount } = counterSlice.actions - let middleware: ReturnType - let store: EnhancedStore + let middleware = createActionListenerMiddleware() + let store = configureStore({ + reducer: counterSlice.reducer, + middleware: (gDM) => gDM().prepend(middleware), + }) beforeEach(() => { middleware = createActionListenerMiddleware() diff --git a/packages/toolkit/src/configureStore.ts b/packages/toolkit/src/configureStore.ts index 86a9190ae5..e0a7896ac2 100644 --- a/packages/toolkit/src/configureStore.ts +++ b/packages/toolkit/src/configureStore.ts @@ -20,7 +20,7 @@ import type { CurriedGetDefaultMiddleware, } from './getDefaultMiddleware' import { curryGetDefaultMiddleware } from './getDefaultMiddleware' -import type { DispatchForMiddlewares, NoInfer } from './tsHelpers' +import type { NoInfer, ExtractDispatchExtensions } from './tsHelpers' const IS_PRODUCTION = process.env.NODE_ENV === 'production' @@ -110,7 +110,7 @@ export interface EnhancedStore< * * @inheritdoc */ - dispatch: Dispatch & DispatchForMiddlewares + dispatch: ExtractDispatchExtensions & Dispatch } /** @@ -160,7 +160,7 @@ export function configureStore< } if ( !IS_PRODUCTION && - finalMiddleware.some((item) => typeof item !== 'function') + finalMiddleware.some((item: any) => typeof item !== 'function') ) { throw new Error( 'each middleware provided to configureStore must be a function' diff --git a/packages/toolkit/src/getDefaultMiddleware.ts b/packages/toolkit/src/getDefaultMiddleware.ts index c08da77b57..e54a667680 100644 --- a/packages/toolkit/src/getDefaultMiddleware.ts +++ b/packages/toolkit/src/getDefaultMiddleware.ts @@ -8,6 +8,7 @@ import { createImmutableStateInvariantMiddleware } from './immutableStateInvaria import type { SerializableStateInvariantMiddlewareOptions } from './serializableStateInvariantMiddleware' import { createSerializableStateInvariantMiddleware } from './serializableStateInvariantMiddleware' +import type { ExcludeFromTuple } from './tsHelpers' import { MiddlewareArray } from './utils' function isBoolean(x: any): x is boolean { @@ -33,9 +34,7 @@ export type ThunkMiddlewareFor< ? never : O extends { thunk: { extraArgument: infer E } } ? ThunkMiddleware - : - | ThunkMiddleware //The ThunkMiddleware with a `null` ExtraArgument is here to provide backwards-compatibility. - | ThunkMiddleware + : ThunkMiddleware export type CurriedGetDefaultMiddleware = < O extends Partial = { @@ -45,7 +44,7 @@ export type CurriedGetDefaultMiddleware = < } >( options?: O -) => MiddlewareArray | ThunkMiddlewareFor> +) => MiddlewareArray], never>> export function curryGetDefaultMiddleware< S = any @@ -76,14 +75,14 @@ export function getDefaultMiddleware< } >( options: O = {} as O -): MiddlewareArray | ThunkMiddlewareFor> { +): MiddlewareArray], never>> { const { thunk = true, immutableCheck = true, serializableCheck = true, } = options - let middlewareArray: Middleware<{}, S>[] = new MiddlewareArray() + let middlewareArray = new MiddlewareArray() if (thunk) { if (isBoolean(thunk)) { diff --git a/packages/toolkit/src/tests/MiddlewareArray.typetest.ts b/packages/toolkit/src/tests/MiddlewareArray.typetest.ts index d9cfa00f50..25bb4cb21d 100644 --- a/packages/toolkit/src/tests/MiddlewareArray.typetest.ts +++ b/packages/toolkit/src/tests/MiddlewareArray.typetest.ts @@ -1,6 +1,5 @@ -import { getDefaultMiddleware } from '@reduxjs/toolkit' +import { getDefaultMiddleware, configureStore } from '@reduxjs/toolkit' import type { Middleware } from 'redux' -import type { DispatchForMiddlewares } from '@internal/tsHelpers' declare const expectType: (t: T) => T @@ -12,103 +11,108 @@ declare const middleware2: Middleware<{ (_: number): string }> -declare const getDispatch: >( - m: M -) => DispatchForMiddlewares - type ThunkReturn = Promise<'thunk'> declare const thunkCreator: () => () => ThunkReturn { - const defaultMiddleware = getDefaultMiddleware() - // prepend single element { - const concatenated = defaultMiddleware.prepend(middleware1) - const dispatch = getDispatch(concatenated) - expectType(dispatch('foo')) - expectType(dispatch(thunkCreator())) + const store = configureStore({ + reducer: () => 0, + middleware: (gDM) => gDM().prepend(middleware1), + }) + expectType(store.dispatch('foo')) + expectType(store.dispatch(thunkCreator())) // @ts-expect-error - expectType(dispatch('foo')) + expectType(store.dispatch('foo')) } - // prepepend multiple (rest) + // prepend multiple (rest) { - const concatenated = defaultMiddleware.prepend(middleware1, middleware2) - const dispatch = getDispatch(concatenated) - expectType(dispatch('foo')) - expectType(dispatch(5)) - expectType(dispatch(thunkCreator())) + const store = configureStore({ + reducer: () => 0, + middleware: (gDM) => gDM().prepend(middleware1, middleware2), + }) + expectType(store.dispatch('foo')) + expectType(store.dispatch(5)) + expectType(store.dispatch(thunkCreator())) // @ts-expect-error - expectType(dispatch('foo')) + expectType(store.dispatch('foo')) } // prepend multiple (array notation) { - const concatenated = defaultMiddleware.prepend([ - middleware1, - middleware2, - ] as const) - const dispatch = getDispatch(concatenated) - expectType(dispatch('foo')) - expectType(dispatch(5)) - expectType(dispatch(thunkCreator())) + const store = configureStore({ + reducer: () => 0, + middleware: (gDM) => gDM().prepend([middleware1, middleware2] as const), + }) + + expectType(store.dispatch('foo')) + expectType(store.dispatch(5)) + expectType(store.dispatch(thunkCreator())) // @ts-expect-error - expectType(dispatch('foo')) + expectType(store.dispatch('foo')) } // concat single element { - const concatenated = defaultMiddleware.concat(middleware1) - const dispatch = getDispatch(concatenated) - expectType(dispatch('foo')) - expectType(dispatch(thunkCreator())) + const store = configureStore({ + reducer: () => 0, + middleware: (gDM) => gDM().concat(middleware1), + }) + + expectType(store.dispatch('foo')) + expectType(store.dispatch(thunkCreator())) // @ts-expect-error - expectType(dispatch('foo')) + expectType(store.dispatch('foo')) } - // prepepend multiple (rest) + // prepend multiple (rest) { - const concatenated = defaultMiddleware.concat(middleware1, middleware2) - const dispatch = getDispatch(concatenated) - expectType(dispatch('foo')) - expectType(dispatch(5)) - expectType(dispatch(thunkCreator())) + const store = configureStore({ + reducer: () => 0, + middleware: (gDM) => gDM().concat(middleware1, middleware2), + }) + + expectType(store.dispatch('foo')) + expectType(store.dispatch(5)) + expectType(store.dispatch(thunkCreator())) // @ts-expect-error - expectType(dispatch('foo')) + expectType(store.dispatch('foo')) } // concat multiple (array notation) { - const concatenated = defaultMiddleware.concat([ - middleware1, - middleware2, - ] as const) - const dispatch = getDispatch(concatenated) - expectType(dispatch('foo')) - expectType(dispatch(5)) - expectType(dispatch(thunkCreator())) + const store = configureStore({ + reducer: () => 0, + middleware: (gDM) => gDM().concat([middleware1, middleware2] as const), + }) + + expectType(store.dispatch('foo')) + expectType(store.dispatch(5)) + expectType(store.dispatch(thunkCreator())) // @ts-expect-error - expectType(dispatch('foo')) + expectType(store.dispatch('foo')) } // concat and prepend { - const concatenated = defaultMiddleware - .concat(middleware1) - .prepend(middleware2) - const dispatch = getDispatch(concatenated) - expectType(dispatch('foo')) - expectType(dispatch(5)) - expectType(dispatch(thunkCreator())) + const store = configureStore({ + reducer: () => 0, + middleware: (gDM) => gDM().concat(middleware1).prepend(middleware2), + }) + + expectType(store.dispatch('foo')) + expectType(store.dispatch(5)) + expectType(store.dispatch(thunkCreator())) // @ts-expect-error - expectType(dispatch('foo')) + expectType(store.dispatch('foo')) } } diff --git a/packages/toolkit/src/tests/configureStore.typetest.ts b/packages/toolkit/src/tests/configureStore.typetest.ts index a956c9cbf1..2f9183095d 100644 --- a/packages/toolkit/src/tests/configureStore.typetest.ts +++ b/packages/toolkit/src/tests/configureStore.typetest.ts @@ -1,15 +1,23 @@ /* eslint-disable no-lone-blocks */ -import type { Dispatch, AnyAction, Middleware, Reducer, Store } from 'redux' +import type { + Dispatch, + AnyAction, + Middleware, + Reducer, + Store, + Action, +} from 'redux' import { applyMiddleware } from 'redux' -import type { PayloadAction } from '@reduxjs/toolkit' +import type { PayloadAction, MiddlewareArray } from '@reduxjs/toolkit' import { configureStore, getDefaultMiddleware, createSlice, } from '@reduxjs/toolkit' -import type { ThunkMiddleware, ThunkAction } from 'redux-thunk' -import thunk, { ThunkDispatch } from 'redux-thunk' +import type { ThunkMiddleware, ThunkAction, ThunkDispatch } from 'redux-thunk' +import thunk from 'redux-thunk' import { expectNotAny, expectType } from './helpers' +import type { IsAny, ExtractDispatchExtensions } from '../tsHelpers' const _anyMiddleware: any = () => () => () => {} @@ -300,14 +308,16 @@ const _anyMiddleware: any = () => () => () => {} * Test: multiple custom middleware */ { + const middleware = [] as any as [ + Middleware<(a: 'a') => 'A', StateA>, + Middleware<(b: 'b') => 'B', StateA>, + ThunkMiddleware + ] const store = configureStore({ reducer: reducerA, - middleware: [] as any as [ - Middleware<(a: 'a') => 'A', StateA>, - Middleware<(b: 'b') => 'B', StateA>, - ThunkMiddleware - ], + middleware, }) + const result: 'A' = store.dispatch('a') const result2: 'B' = store.dispatch('b') const result3: Promise<'A'> = store.dispatch(thunkA()) @@ -324,7 +334,9 @@ const _anyMiddleware: any = () => () => () => {} undefined, AnyAction >) - // null was previously documented in the redux docs + // `null` for the `extra` generic was previously documented in the RTK "Advanced Tutorial", but + // is a bad pattern and users should use `unknown` instead + // @ts-expect-error store.dispatch(function () {} as ThunkAction) // unknown is the best way to type a ThunkAction if you do not care // about the value of the extraArgument, as it will always work with every @@ -338,13 +350,14 @@ const _anyMiddleware: any = () => () => () => {} * Test: custom middleware and getDefaultMiddleware */ { + const middleware = getDefaultMiddleware().prepend( + (() => {}) as any as Middleware<(a: 'a') => 'A', StateA> + ) const store = configureStore({ reducer: reducerA, - middleware: [ - (() => {}) as any as Middleware<(a: 'a') => 'A', StateA>, - ...getDefaultMiddleware(), - ] as const, + middleware, }) + const result1: 'A' = store.dispatch('a') const result2: Promise<'A'> = store.dispatch(thunkA()) // @ts-expect-error @@ -400,10 +413,10 @@ const _anyMiddleware: any = () => () => () => {} const store = configureStore({ reducer: reducerA, middleware: (getDefaultMiddleware) => - [ - (() => {}) as any as Middleware<(a: 'a') => 'A', StateA>, - ...getDefaultMiddleware(), - ] as const, + getDefaultMiddleware().prepend((() => {}) as any as Middleware< + (a: 'a') => 'A', + StateA + >), }) const result1: 'A' = store.dispatch('a') const result2: Promise<'A'> = store.dispatch(thunkA()) @@ -438,10 +451,9 @@ const _anyMiddleware: any = () => () => () => {} const store = configureStore({ reducer: reducerA, middleware: (getDefaultMiddleware) => - [ - (() => {}) as any as Middleware<(a: 'a') => 'A', StateA>, - ...getDefaultMiddleware({ thunk: false }), - ] as const, + getDefaultMiddleware({ thunk: false }).prepend( + (() => {}) as any as Middleware<(a: 'a') => 'A', StateA> + ), }) const result1: 'A' = store.dispatch('a') // @ts-expect-error @@ -460,4 +472,57 @@ const _anyMiddleware: any = () => () => () => {} expectNotAny(store.dispatch) } + + { + interface CounterState { + value: number + } + + const counterSlice = createSlice({ + name: 'counter', + initialState: { value: 0 } as CounterState, + reducers: { + increment(state) { + state.value += 1 + }, + decrement(state) { + state.value -= 1 + }, + // Use the PayloadAction type to declare the contents of `action.payload` + incrementByAmount: (state, action: PayloadAction) => { + state.value += action.payload + }, + }, + }) + + type Unsubscribe = () => void + + // A fake middleware that tells TS that an unsubscribe callback is being returned for a given action + // This is the same signature that the "listener" middleware uses + const dummyMiddleware: Middleware< + { + (action: Action<'actionListenerMiddleware/add'>): Unsubscribe + }, + CounterState + > = (storeApi) => (next) => (action) => {} + + const store = configureStore({ + reducer: counterSlice.reducer, + middleware: (gDM) => gDM().prepend(dummyMiddleware), + }) + + // Order matters here! We need the listener type to come first, otherwise + // the thunk middleware type kicks in and TS thinks a plain action is being returned + expectType< + ((action: Action<'actionListenerMiddleware/add'>) => Unsubscribe) & + ThunkDispatch & + Dispatch + >(store.dispatch) + + const unsubscribe = store.dispatch({ + type: 'actionListenerMiddleware/add', + } as const) + + expectType(unsubscribe) + } } diff --git a/packages/toolkit/src/tests/getDefaultMiddleware.test.ts b/packages/toolkit/src/tests/getDefaultMiddleware.test.ts index 06264cb281..2367ae4040 100644 --- a/packages/toolkit/src/tests/getDefaultMiddleware.test.ts +++ b/packages/toolkit/src/tests/getDefaultMiddleware.test.ts @@ -1,10 +1,20 @@ -import type { AnyAction, Middleware, ThunkAction } from '@reduxjs/toolkit' +import type { + AnyAction, + Middleware, + ThunkAction, + Action, + ThunkDispatch, + Dispatch, +} from '@reduxjs/toolkit' import { getDefaultMiddleware, MiddlewareArray, configureStore, } from '@reduxjs/toolkit' import thunk from 'redux-thunk' +import type { ThunkMiddleware } from 'redux-thunk' + +import { expectType } from './helpers' describe('getDefaultMiddleware', () => { const ORIGINAL_NODE_ENV = process.env.NODE_ENV @@ -27,6 +37,7 @@ describe('getDefaultMiddleware', () => { it('removes the thunk middleware if disabled', () => { const middleware = getDefaultMiddleware({ thunk: false }) + // @ts-ignore expect(middleware.includes(thunk)).toBe(false) expect(middleware.length).toBe(2) }) @@ -44,13 +55,46 @@ describe('getDefaultMiddleware', () => { }) it('allows passing options to thunk', () => { - const extraArgument = 42 + const extraArgument = 42 as const const middleware = getDefaultMiddleware({ thunk: { extraArgument }, immutableCheck: false, serializableCheck: false, }) + const m2 = getDefaultMiddleware({ + thunk: false, + }) + + expectType>(m2) + + const dummyMiddleware: Middleware< + { + (action: Action<'actionListenerMiddleware/add'>): () => void + }, + { counter: number } + > = (storeApi) => (next) => (action) => {} + + const dummyMiddleware2: Middleware = (storeApi) => (next) => (action) => {} + + const m3 = middleware.concat(dummyMiddleware, dummyMiddleware2) + + expectType< + MiddlewareArray< + [ + ThunkMiddleware, + Middleware< + (action: Action<'actionListenerMiddleware/add'>) => () => void, + { + counter: number + }, + Dispatch + >, + Middleware<{}, any, Dispatch> + ] + > + >(m3) + const testThunk: ThunkAction = ( dispatch, getState, @@ -66,6 +110,10 @@ describe('getDefaultMiddleware', () => { middleware, }) + expectType & Dispatch>( + store.dispatch + ) + store.dispatch(testThunk) }) diff --git a/packages/toolkit/src/tsHelpers.ts b/packages/toolkit/src/tsHelpers.ts index e91457326e..342f0b0c7b 100644 --- a/packages/toolkit/src/tsHelpers.ts +++ b/packages/toolkit/src/tsHelpers.ts @@ -1,4 +1,5 @@ -import type { Middleware } from 'redux' +import type { Middleware, Dispatch } from 'redux' +import type { MiddlewareArray } from './utils' /** * return True if T is `any`, otherwise return False @@ -65,20 +66,30 @@ export type IsUnknownOrNonInferrable = AtLeastTS35< 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 - ? IsAny - : never - : never - : never +// Appears to have a convenient side effect of ignoring `never` even if that's not what you specified +export type ExcludeFromTuple = T extends [ + infer Head, + ...infer Tail +] + ? ExcludeFromTuple + : Acc + +type ExtractDispatchFromMiddlewareTuple< + MiddlewareTuple extends any[], + Acc extends {} +> = MiddlewareTuple extends [infer Head, ...infer Tail] + ? ExtractDispatchFromMiddlewareTuple< + Tail, + Acc & (Head extends Middleware ? IsAny : {}) > + : Acc + +export type ExtractDispatchExtensions = M extends MiddlewareArray< + infer MiddlewareTuple +> + ? ExtractDispatchFromMiddlewareTuple + : M extends Middleware[] + ? ExtractDispatchFromMiddlewareTuple<[...M], {}> : never /** diff --git a/packages/toolkit/src/utils.ts b/packages/toolkit/src/utils.ts index 037db322fd..5a3c77e1da 100644 --- a/packages/toolkit/src/utils.ts +++ b/packages/toolkit/src/utils.ts @@ -26,10 +26,9 @@ It is disabled in production builds, so you don't need to worry about that.`) * @public */ export class MiddlewareArray< - Middlewares extends Middleware -> extends Array { - constructor(arrayLength?: number) - constructor(...items: Middlewares[]) + Middlewares extends Middleware[] +> extends Array { + constructor(...items: Middlewares) constructor(...args: any[]) { super(...args) Object.setPrototypeOf(this, MiddlewareArray.prototype) @@ -41,22 +40,22 @@ export class MiddlewareArray< concat>>( items: AdditionalMiddlewares - ): MiddlewareArray + ): MiddlewareArray<[...Middlewares, ...AdditionalMiddlewares]> concat>>( ...items: AdditionalMiddlewares - ): MiddlewareArray + ): MiddlewareArray<[...Middlewares, ...AdditionalMiddlewares]> concat(...arr: any[]) { return super.concat.apply(this, arr) } prepend>>( items: AdditionalMiddlewares - ): MiddlewareArray + ): MiddlewareArray<[...AdditionalMiddlewares, ...Middlewares]> prepend>>( ...items: AdditionalMiddlewares - ): MiddlewareArray + ): MiddlewareArray<[...AdditionalMiddlewares, ...Middlewares]> prepend(...arr: any[]) { if (arr.length === 1 && Array.isArray(arr[0])) {