Skip to content

Commit 762f729

Browse files
committed
Rewrite gDM type inference for better extraction of dispatch
`getDefaultMiddleware` defines an initial type that may or may not include `thunk`, depending on options. From there, `EnhancedStore` tries to extract any extensions to `dispatch` from "the type of all middleware as an array", to fully infer the correct type of `store.dispatch`. This was flaky. In particular, the `MiddlewareArray` type ended up with "a union of all middleware" type, and that meant the extensions weren't always getting read correctly. This commit rewrites the inference to be more correct: - `MiddlewareArray` now tracks an exact tuple type for its contents - `.concat/prepend` now update that middleware tuple type - We exactly extract the dispatch extensions from the tuple, and retain the exact order of the middleware as declared - All the extensions are intersected together with `Dispatch` This should now correctly represent cases like the listener middleware returning an `Unsubscribe` callback in response to `dispatch(addListenerAction())` This commit also drops support for passing `null` as the `extraArg` generic for `ThunkAction`, as that is an outdated pattern and we want to discourage it.
1 parent f86d1e6 commit 762f729

File tree

7 files changed

+181
-57
lines changed

7 files changed

+181
-57
lines changed

packages/toolkit/src/configureStore.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import type {
2020
CurriedGetDefaultMiddleware,
2121
} from './getDefaultMiddleware'
2222
import { curryGetDefaultMiddleware } from './getDefaultMiddleware'
23-
import type { DispatchForMiddlewares, NoInfer } from './tsHelpers'
23+
import type { NoInfer, ExtractDispatchExtensions } from './tsHelpers'
2424

2525
const IS_PRODUCTION = process.env.NODE_ENV === 'production'
2626

@@ -110,7 +110,7 @@ export interface EnhancedStore<
110110
*
111111
* @inheritdoc
112112
*/
113-
dispatch: Dispatch<A> & DispatchForMiddlewares<M>
113+
dispatch: ExtractDispatchExtensions<M> & Dispatch<A>
114114
}
115115

116116
/**
@@ -160,7 +160,7 @@ export function configureStore<
160160
}
161161
if (
162162
!IS_PRODUCTION &&
163-
finalMiddleware.some((item) => typeof item !== 'function')
163+
finalMiddleware.some((item: any) => typeof item !== 'function')
164164
) {
165165
throw new Error(
166166
'each middleware provided to configureStore must be a function'

packages/toolkit/src/getDefaultMiddleware.ts

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { createImmutableStateInvariantMiddleware } from './immutableStateInvaria
88

99
import type { SerializableStateInvariantMiddlewareOptions } from './serializableStateInvariantMiddleware'
1010
import { createSerializableStateInvariantMiddleware } from './serializableStateInvariantMiddleware'
11+
import type { ExcludeFromTuple } from './tsHelpers'
1112
import { MiddlewareArray } from './utils'
1213

1314
function isBoolean(x: any): x is boolean {
@@ -33,9 +34,7 @@ export type ThunkMiddlewareFor<
3334
? never
3435
: O extends { thunk: { extraArgument: infer E } }
3536
? ThunkMiddleware<S, AnyAction, E>
36-
:
37-
| ThunkMiddleware<S, AnyAction, null> //The ThunkMiddleware with a `null` ExtraArgument is here to provide backwards-compatibility.
38-
| ThunkMiddleware<S, AnyAction>
37+
: ThunkMiddleware<S, AnyAction>
3938

4039
export type CurriedGetDefaultMiddleware<S = any> = <
4140
O extends Partial<GetDefaultMiddlewareOptions> = {
@@ -45,7 +44,7 @@ export type CurriedGetDefaultMiddleware<S = any> = <
4544
}
4645
>(
4746
options?: O
48-
) => MiddlewareArray<Middleware<{}, S> | ThunkMiddlewareFor<S, O>>
47+
) => MiddlewareArray<ExcludeFromTuple<[ThunkMiddlewareFor<S, O>], never>>
4948

5049
export function curryGetDefaultMiddleware<
5150
S = any
@@ -76,14 +75,14 @@ export function getDefaultMiddleware<
7675
}
7776
>(
7877
options: O = {} as O
79-
): MiddlewareArray<Middleware<{}, S> | ThunkMiddlewareFor<S, O>> {
78+
): MiddlewareArray<ExcludeFromTuple<[ThunkMiddlewareFor<S, O>], never>> {
8079
const {
8180
thunk = true,
8281
immutableCheck = true,
8382
serializableCheck = true,
8483
} = options
8584

86-
let middlewareArray: Middleware<{}, S>[] = new MiddlewareArray()
85+
let middlewareArray = new MiddlewareArray<Middleware[]>()
8786

8887
if (thunk) {
8988
if (isBoolean(thunk)) {

packages/toolkit/src/tests/MiddlewareArray.typetest.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { getDefaultMiddleware } from '@reduxjs/toolkit'
22
import type { Middleware } from 'redux'
3-
import type { DispatchForMiddlewares } from '@internal/tsHelpers'
3+
import type { ExtractDispatchExtensions } from '@internal/tsHelpers'
4+
import thunk from 'redux-thunk'
5+
import { MiddlewareArray } from '../utils'
46

57
declare const expectType: <T>(t: T) => T
68

@@ -14,7 +16,7 @@ declare const middleware2: Middleware<{
1416

1517
declare const getDispatch: <M extends Array<Middleware>>(
1618
m: M
17-
) => DispatchForMiddlewares<M>
19+
) => ExtractDispatchExtensions<M>
1820

1921
type ThunkReturn = Promise<'thunk'>
2022
declare const thunkCreator: () => () => ThunkReturn

packages/toolkit/src/tests/configureStore.typetest.ts

Lines changed: 87 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,23 @@
11
/* eslint-disable no-lone-blocks */
2-
import type { Dispatch, AnyAction, Middleware, Reducer, Store } from 'redux'
2+
import type {
3+
Dispatch,
4+
AnyAction,
5+
Middleware,
6+
Reducer,
7+
Store,
8+
Action,
9+
} from 'redux'
310
import { applyMiddleware } from 'redux'
4-
import type { PayloadAction } from '@reduxjs/toolkit'
11+
import type { PayloadAction, MiddlewareArray } from '@reduxjs/toolkit'
512
import {
613
configureStore,
714
getDefaultMiddleware,
815
createSlice,
916
} from '@reduxjs/toolkit'
10-
import type { ThunkMiddleware, ThunkAction } from 'redux-thunk'
11-
import thunk, { ThunkDispatch } from 'redux-thunk'
17+
import type { ThunkMiddleware, ThunkAction, ThunkDispatch } from 'redux-thunk'
18+
import thunk from 'redux-thunk'
1219
import { expectNotAny, expectType } from './helpers'
20+
import type { IsAny, ExtractDispatchExtensions } from '../tsHelpers'
1321

1422
const _anyMiddleware: any = () => () => () => {}
1523

@@ -300,14 +308,16 @@ const _anyMiddleware: any = () => () => () => {}
300308
* Test: multiple custom middleware
301309
*/
302310
{
311+
const middleware = [] as any as [
312+
Middleware<(a: 'a') => 'A', StateA>,
313+
Middleware<(b: 'b') => 'B', StateA>,
314+
ThunkMiddleware<StateA>
315+
]
303316
const store = configureStore({
304317
reducer: reducerA,
305-
middleware: [] as any as [
306-
Middleware<(a: 'a') => 'A', StateA>,
307-
Middleware<(b: 'b') => 'B', StateA>,
308-
ThunkMiddleware<StateA>
309-
],
318+
middleware,
310319
})
320+
311321
const result: 'A' = store.dispatch('a')
312322
const result2: 'B' = store.dispatch('b')
313323
const result3: Promise<'A'> = store.dispatch(thunkA())
@@ -324,7 +334,9 @@ const _anyMiddleware: any = () => () => () => {}
324334
undefined,
325335
AnyAction
326336
>)
327-
// null was previously documented in the redux docs
337+
// `null` for the `extra` generic was previously documented in the RTK "Advanced Tutorial", but
338+
// is a bad pattern and users should use `unknown` instead
339+
// @ts-expect-error
328340
store.dispatch(function () {} as ThunkAction<void, {}, null, AnyAction>)
329341
// unknown is the best way to type a ThunkAction if you do not care
330342
// about the value of the extraArgument, as it will always work with every
@@ -338,13 +350,14 @@ const _anyMiddleware: any = () => () => () => {}
338350
* Test: custom middleware and getDefaultMiddleware
339351
*/
340352
{
353+
const middleware = getDefaultMiddleware<StateA>().prepend(
354+
(() => {}) as any as Middleware<(a: 'a') => 'A', StateA>
355+
)
341356
const store = configureStore({
342357
reducer: reducerA,
343-
middleware: [
344-
(() => {}) as any as Middleware<(a: 'a') => 'A', StateA>,
345-
...getDefaultMiddleware<StateA>(),
346-
] as const,
358+
middleware,
347359
})
360+
348361
const result1: 'A' = store.dispatch('a')
349362
const result2: Promise<'A'> = store.dispatch(thunkA())
350363
// @ts-expect-error
@@ -400,10 +413,10 @@ const _anyMiddleware: any = () => () => () => {}
400413
const store = configureStore({
401414
reducer: reducerA,
402415
middleware: (getDefaultMiddleware) =>
403-
[
404-
(() => {}) as any as Middleware<(a: 'a') => 'A', StateA>,
405-
...getDefaultMiddleware(),
406-
] as const,
416+
getDefaultMiddleware().prepend((() => {}) as any as Middleware<
417+
(a: 'a') => 'A',
418+
StateA
419+
>),
407420
})
408421
const result1: 'A' = store.dispatch('a')
409422
const result2: Promise<'A'> = store.dispatch(thunkA())
@@ -438,10 +451,9 @@ const _anyMiddleware: any = () => () => () => {}
438451
const store = configureStore({
439452
reducer: reducerA,
440453
middleware: (getDefaultMiddleware) =>
441-
[
442-
(() => {}) as any as Middleware<(a: 'a') => 'A', StateA>,
443-
...getDefaultMiddleware({ thunk: false }),
444-
] as const,
454+
getDefaultMiddleware({ thunk: false }).prepend(
455+
(() => {}) as any as Middleware<(a: 'a') => 'A', StateA>
456+
),
445457
})
446458
const result1: 'A' = store.dispatch('a')
447459
// @ts-expect-error
@@ -460,4 +472,57 @@ const _anyMiddleware: any = () => () => () => {}
460472

461473
expectNotAny(store.dispatch)
462474
}
475+
476+
{
477+
interface CounterState {
478+
value: number
479+
}
480+
481+
const counterSlice = createSlice({
482+
name: 'counter',
483+
initialState: { value: 0 } as CounterState,
484+
reducers: {
485+
increment(state) {
486+
state.value += 1
487+
},
488+
decrement(state) {
489+
state.value -= 1
490+
},
491+
// Use the PayloadAction type to declare the contents of `action.payload`
492+
incrementByAmount: (state, action: PayloadAction<number>) => {
493+
state.value += action.payload
494+
},
495+
},
496+
})
497+
498+
type Unsubscribe = () => void
499+
500+
// A fake middleware that tells TS that an unsubscribe callback is being returned for a given action
501+
// This is the same signature that the "listener" middleware uses
502+
const dummyMiddleware: Middleware<
503+
{
504+
(action: Action<'actionListenerMiddleware/add'>): Unsubscribe
505+
},
506+
CounterState
507+
> = (storeApi) => (next) => (action) => {}
508+
509+
const store = configureStore({
510+
reducer: counterSlice.reducer,
511+
middleware: (gDM) => gDM().prepend(dummyMiddleware),
512+
})
513+
514+
// Order matters here! We need the listener type to come first, otherwise
515+
// the thunk middleware type kicks in and TS thinks a plain action is being returned
516+
expectType<
517+
((action: Action<'actionListenerMiddleware/add'>) => Unsubscribe) &
518+
ThunkDispatch<CounterState, undefined, AnyAction> &
519+
Dispatch<AnyAction>
520+
>(store.dispatch)
521+
522+
const unsubscribe = store.dispatch({
523+
type: 'actionListenerMiddleware/add',
524+
} as const)
525+
526+
expectType<Unsubscribe>(unsubscribe)
527+
}
463528
}

packages/toolkit/src/tests/getDefaultMiddleware.test.ts

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,20 @@
1-
import type { AnyAction, Middleware, ThunkAction } from '@reduxjs/toolkit'
1+
import type {
2+
AnyAction,
3+
Middleware,
4+
ThunkAction,
5+
Action,
6+
ThunkDispatch,
7+
Dispatch,
8+
} from '@reduxjs/toolkit'
29
import {
310
getDefaultMiddleware,
411
MiddlewareArray,
512
configureStore,
613
} from '@reduxjs/toolkit'
714
import thunk from 'redux-thunk'
15+
import type { ThunkMiddleware } from 'redux-thunk'
16+
17+
import { expectType } from './helpers'
818

919
describe('getDefaultMiddleware', () => {
1020
const ORIGINAL_NODE_ENV = process.env.NODE_ENV
@@ -27,6 +37,7 @@ describe('getDefaultMiddleware', () => {
2737

2838
it('removes the thunk middleware if disabled', () => {
2939
const middleware = getDefaultMiddleware({ thunk: false })
40+
// @ts-ignore
3041
expect(middleware.includes(thunk)).toBe(false)
3142
expect(middleware.length).toBe(2)
3243
})
@@ -44,13 +55,46 @@ describe('getDefaultMiddleware', () => {
4455
})
4556

4657
it('allows passing options to thunk', () => {
47-
const extraArgument = 42
58+
const extraArgument = 42 as const
4859
const middleware = getDefaultMiddleware({
4960
thunk: { extraArgument },
5061
immutableCheck: false,
5162
serializableCheck: false,
5263
})
5364

65+
const m2 = getDefaultMiddleware({
66+
thunk: false,
67+
})
68+
69+
expectType<MiddlewareArray<[]>>(m2)
70+
71+
const dummyMiddleware: Middleware<
72+
{
73+
(action: Action<'actionListenerMiddleware/add'>): () => void
74+
},
75+
{ counter: number }
76+
> = (storeApi) => (next) => (action) => {}
77+
78+
const dummyMiddleware2: Middleware = (storeApi) => (next) => (action) => {}
79+
80+
const m3 = middleware.concat(dummyMiddleware, dummyMiddleware2)
81+
82+
expectType<
83+
MiddlewareArray<
84+
[
85+
ThunkMiddleware<any, AnyAction, 42>,
86+
Middleware<
87+
(action: Action<'actionListenerMiddleware/add'>) => () => void,
88+
{
89+
counter: number
90+
},
91+
Dispatch<AnyAction>
92+
>,
93+
Middleware<{}, any, Dispatch<AnyAction>>
94+
]
95+
>
96+
>(m3)
97+
5498
const testThunk: ThunkAction<void, {}, number, AnyAction> = (
5599
dispatch,
56100
getState,
@@ -66,6 +110,10 @@ describe('getDefaultMiddleware', () => {
66110
middleware,
67111
})
68112

113+
expectType<ThunkDispatch<any, 42, AnyAction> & Dispatch<AnyAction>>(
114+
store.dispatch
115+
)
116+
69117
store.dispatch(testThunk)
70118
})
71119

packages/toolkit/src/tsHelpers.ts

Lines changed: 25 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import type { Middleware } from 'redux'
1+
import type { Middleware, Dispatch } from 'redux'
2+
import type { MiddlewareArray } from './utils'
23

34
/**
45
* return True if T is `any`, otherwise return False
@@ -65,20 +66,30 @@ export type IsUnknownOrNonInferrable<T, True, False> = AtLeastTS35<
6566
IsEmptyObj<T, True, IsUnknown<T, True, False>>
6667
>
6768

68-
/**
69-
* Combines all dispatch signatures of all middlewares in the array `M` into
70-
* one intersected dispatch signature.
71-
*/
72-
export type DispatchForMiddlewares<M> = M extends ReadonlyArray<any>
73-
? UnionToIntersection<
74-
M[number] extends infer MiddlewareValues
75-
? MiddlewareValues extends Middleware<infer DispatchExt, any, any>
76-
? DispatchExt extends Function
77-
? IsAny<DispatchExt, never, DispatchExt>
78-
: never
79-
: never
80-
: never
69+
// Appears to have a convenient side effect of ignoring `never` even if that's not what you specified
70+
export type ExcludeFromTuple<T, E, Acc extends unknown[] = []> = T extends [
71+
infer Head,
72+
...infer Tail
73+
]
74+
? ExcludeFromTuple<Tail, E, [...Acc, ...([Head] extends [E] ? [] : [Head])]>
75+
: Acc
76+
77+
type ExtractDispatchFromMiddlewareTuple<
78+
MiddlewareTuple extends any[],
79+
Acc extends {}
80+
> = MiddlewareTuple extends [infer Head, ...infer Tail]
81+
? ExtractDispatchFromMiddlewareTuple<
82+
Tail,
83+
Acc & (Head extends Middleware<infer D, any> ? IsAny<D, {}, D> : {})
8184
>
85+
: Acc
86+
87+
export type ExtractDispatchExtensions<M> = M extends MiddlewareArray<
88+
infer MiddlewareTuple
89+
>
90+
? ExtractDispatchFromMiddlewareTuple<MiddlewareTuple, {}>
91+
: M extends Middleware[]
92+
? ExtractDispatchFromMiddlewareTuple<[...M], {}>
8293
: never
8394

8495
/**

0 commit comments

Comments
 (0)