diff --git a/docs/usage/usage-with-typescript.md b/docs/usage/usage-with-typescript.md index 744b4075cf..1b123f2ba7 100644 --- a/docs/usage/usage-with-typescript.md +++ b/docs/usage/usage-with-typescript.md @@ -62,19 +62,18 @@ export type RootState = ReturnType export default store ``` -If you pass the reducers directly to `configureStore()` and do not define the root reducer explicitly, there is no reference to `rootReducer`. +If you pass the reducers directly to `configureStore()` and do not define the root reducer explicitly, there is no reference to `rootReducer`. Instead, you can refer to `store.getState`, in order to get the `State` type. ```typescript import { configureStore } from '@reduxjs/toolkit' import rootReducer from './rootReducer' const store = configureStore({ - reducer: rootReducer + reducer: rootReducer, }) export type RootState = ReturnType ``` - ### Getting the `Dispatch` type If you want to get the `Dispatch` type from your store, you can extract it after creating the store. It is recommended to give the type a different name like `AppDispatch` to prevent confusion, as the type name `Dispatch` is usually overused. You may also find it to be more convenient to export a hook like `useAppDispatch` shown below, then using it wherever you'd call `useDispatch`. @@ -489,6 +488,8 @@ const wrappedSlice = createGenericSlice({ ## `createAsyncThunk` +### Basic `createAsyncThunk` Types + In the most common use cases, you should not need to explicitly declare any types for the `createAsyncThunk` call itself. Just provide a type for the first argument to the `payloadCreator` argument as you would for any function argument, and the resulting thunk will accept the same type as its input parameter. @@ -518,8 +519,12 @@ const fetchUserById = createAsyncThunk( const lastReturnedAction = await store.dispatch(fetchUserById(3)) ``` +### Typing the `thunkApi` Object + The second argument to the `payloadCreator`, known as `thunkApi`, is an object containing references to the `dispatch`, `getState`, and `extra` arguments from the thunk middleware as well as a utility function called `rejectWithValue`. If you want to use these from within the `payloadCreator`, you will need to define some generic arguments, as the types for these arguments cannot be inferred. Also, as TS cannot mix explicit and inferred generic parameters, from this point on you'll have to define the `Returned` and `ThunkArg` generic parameter as well. +#### Manually Defining `thunkApi` Types + To define the types for these arguments, pass an object as the third generic argument, with type declarations for some or all of these fields: ```ts @@ -662,6 +667,23 @@ const handleUpdateUser = async (userData) => { } ``` +#### Defining a Pre-Typed `createAsyncThunk` + +As of RTK 1.9, you can define a "pre-typed" version of `createAsyncThunk` that can have the types for `state`, `dispatch`, and `extra` built in. This lets you set up those types once, so you don't have to repeat them each time you call `createAsyncThunk`. + +To do this, call `createAsyncThunk.withTypes<>()`, and pass in an object containing the field names and types for any of the fields in the `AsyncThunkConfig` type listed above. This might look like: + +```ts +const createAppAsyncThunk = createAsyncThunk.withTypes<{ + state: RootState + dispatch: AppDispatch + rejectValue: string + extra: { s: string; n: number } +}>() +``` + +Import and use that pre-typed `createAppAsyncThunk` instead of the original, and the types will be used automatically: + ## `createEntityAdapter` Typing `createEntityAdapter` only requires you to specify the entity type as the single generic argument. diff --git a/packages/toolkit/src/createAsyncThunk.ts b/packages/toolkit/src/createAsyncThunk.ts index f2ece1f0f1..937ac6f15c 100644 --- a/packages/toolkit/src/createAsyncThunk.ts +++ b/packages/toolkit/src/createAsyncThunk.ts @@ -5,7 +5,7 @@ import type { } from './createAction' import { createAction } from './createAction' import type { ThunkDispatch } from 'redux-thunk' -import type { FallbackIfUnknown, IsAny, IsUnknown } from './tsHelpers' +import type { FallbackIfUnknown, Id, IsAny, IsUnknown } from './tsHelpers' import { nanoid } from './nanoid' // @ts-ignore we need the import of these types due to a bundling issue. @@ -416,269 +416,302 @@ export type AsyncThunk< typePrefix: string } -/** - * - * @param typePrefix - * @param payloadCreator - * @param options - * - * @public - */ -// separate signature without `AsyncThunkConfig` for better inference -export function createAsyncThunk( - typePrefix: string, - payloadCreator: AsyncThunkPayloadCreator, - options?: AsyncThunkOptions -): AsyncThunk +type OverrideThunkApiConfigs = Id< + NewConfig & Omit +> -/** - * - * @param typePrefix - * @param payloadCreator - * @param options - * - * @public - */ -export function createAsyncThunk< - Returned, - ThunkArg, - ThunkApiConfig extends AsyncThunkConfig ->( - typePrefix: string, - payloadCreator: AsyncThunkPayloadCreator, - options?: AsyncThunkOptions -): AsyncThunk +type CreateAsyncThunk = { + /** + * + * @param typePrefix + * @param payloadCreator + * @param options + * + * @public + */ + // separate signature without `AsyncThunkConfig` for better inference + ( + typePrefix: string, + payloadCreator: AsyncThunkPayloadCreator< + Returned, + ThunkArg, + CurriedThunkApiConfig + >, + options?: AsyncThunkOptions + ): AsyncThunk -export function createAsyncThunk< - Returned, - ThunkArg, - ThunkApiConfig extends AsyncThunkConfig ->( - typePrefix: string, - payloadCreator: AsyncThunkPayloadCreator, - options?: AsyncThunkOptions -): AsyncThunk { - type RejectedValue = GetRejectValue - type PendingMeta = GetPendingMeta - type FulfilledMeta = GetFulfilledMeta - type RejectedMeta = GetRejectedMeta - - const fulfilled: AsyncThunkFulfilledActionCreator< + /** + * + * @param typePrefix + * @param payloadCreator + * @param options + * + * @public + */ + ( + typePrefix: string, + payloadCreator: AsyncThunkPayloadCreator< + Returned, + ThunkArg, + OverrideThunkApiConfigs + >, + options?: AsyncThunkOptions< + ThunkArg, + OverrideThunkApiConfigs + > + ): AsyncThunk< Returned, ThunkArg, - ThunkApiConfig - > = createAction( - typePrefix + '/fulfilled', - ( - payload: Returned, - requestId: string, - arg: ThunkArg, - meta?: FulfilledMeta - ) => ({ - payload, - meta: { - ...((meta as any) || {}), - arg, - requestId, - requestStatus: 'fulfilled' as const, - }, - }) - ) + OverrideThunkApiConfigs + > - const pending: AsyncThunkPendingActionCreator = - createAction( - typePrefix + '/pending', - (requestId: string, arg: ThunkArg, meta?: PendingMeta) => ({ - payload: undefined, - meta: { - ...((meta as any) || {}), - arg, - requestId, - requestStatus: 'pending' as const, - }, - }) - ) + withTypes(): CreateAsyncThunk< + OverrideThunkApiConfigs + > +} - const rejected: AsyncThunkRejectedActionCreator = - createAction( - typePrefix + '/rejected', +export const createAsyncThunk = (() => { + function createAsyncThunk< + Returned, + ThunkArg, + ThunkApiConfig extends AsyncThunkConfig + >( + typePrefix: string, + payloadCreator: AsyncThunkPayloadCreator< + Returned, + ThunkArg, + ThunkApiConfig + >, + options?: AsyncThunkOptions + ): AsyncThunk { + type RejectedValue = GetRejectValue + type PendingMeta = GetPendingMeta + type FulfilledMeta = GetFulfilledMeta + type RejectedMeta = GetRejectedMeta + + const fulfilled: AsyncThunkFulfilledActionCreator< + Returned, + ThunkArg, + ThunkApiConfig + > = createAction( + typePrefix + '/fulfilled', ( - error: Error | null, + payload: Returned, requestId: string, arg: ThunkArg, - payload?: RejectedValue, - meta?: RejectedMeta + meta?: FulfilledMeta ) => ({ payload, - error: ((options && options.serializeError) || miniSerializeError)( - error || 'Rejected' - ) as GetSerializedErrorType, meta: { ...((meta as any) || {}), arg, requestId, - rejectedWithValue: !!payload, - requestStatus: 'rejected' as const, - aborted: error?.name === 'AbortError', - condition: error?.name === 'ConditionError', + requestStatus: 'fulfilled' as const, }, }) ) - let displayedWarning = false - - const AC = - typeof AbortController !== 'undefined' - ? AbortController - : class implements AbortController { - signal = { - aborted: false, - addEventListener() {}, - dispatchEvent() { - return false - }, - onabort() {}, - removeEventListener() {}, - reason: undefined, - throwIfAborted() {}, - } - abort() { - if (process.env.NODE_ENV !== 'production') { - if (!displayedWarning) { - displayedWarning = true - console.info( - `This platform does not implement AbortController. + const pending: AsyncThunkPendingActionCreator = + createAction( + typePrefix + '/pending', + (requestId: string, arg: ThunkArg, meta?: PendingMeta) => ({ + payload: undefined, + meta: { + ...((meta as any) || {}), + arg, + requestId, + requestStatus: 'pending' as const, + }, + }) + ) + + const rejected: AsyncThunkRejectedActionCreator = + createAction( + typePrefix + '/rejected', + ( + error: Error | null, + requestId: string, + arg: ThunkArg, + payload?: RejectedValue, + meta?: RejectedMeta + ) => ({ + payload, + error: ((options && options.serializeError) || miniSerializeError)( + error || 'Rejected' + ) as GetSerializedErrorType, + meta: { + ...((meta as any) || {}), + arg, + requestId, + rejectedWithValue: !!payload, + requestStatus: 'rejected' as const, + aborted: error?.name === 'AbortError', + condition: error?.name === 'ConditionError', + }, + }) + ) + + let displayedWarning = false + + const AC = + typeof AbortController !== 'undefined' + ? AbortController + : class implements AbortController { + signal = { + aborted: false, + addEventListener() {}, + dispatchEvent() { + return false + }, + onabort() {}, + removeEventListener() {}, + reason: undefined, + throwIfAborted() {}, + } + abort() { + if (process.env.NODE_ENV !== 'production') { + if (!displayedWarning) { + displayedWarning = true + console.info( + `This platform does not implement AbortController. If you want to use the AbortController to react to \`abort\` events, please consider importing a polyfill like 'abortcontroller-polyfill/dist/abortcontroller-polyfill-only'.` - ) + ) + } } } } - } - function actionCreator( - arg: ThunkArg - ): AsyncThunkAction { - return (dispatch, getState, extra) => { - const requestId = options?.idGenerator - ? options.idGenerator(arg) - : nanoid() - - const abortController = new AC() - let abortReason: string | undefined - - const abortedPromise = new Promise((_, reject) => - abortController.signal.addEventListener('abort', () => - reject({ name: 'AbortError', message: abortReason || 'Aborted' }) + function actionCreator( + arg: ThunkArg + ): AsyncThunkAction { + return (dispatch, getState, extra) => { + const requestId = options?.idGenerator + ? options.idGenerator(arg) + : nanoid() + + const abortController = new AC() + let abortReason: string | undefined + + const abortedPromise = new Promise((_, reject) => + abortController.signal.addEventListener('abort', () => + reject({ name: 'AbortError', message: abortReason || 'Aborted' }) + ) ) - ) - let started = false - function abort(reason?: string) { - if (started) { - abortReason = reason - abortController.abort() + let started = false + function abort(reason?: string) { + if (started) { + abortReason = reason + abortController.abort() + } } - } - const promise = (async function () { - let finalAction: ReturnType - try { - let conditionResult = options?.condition?.(arg, { getState, extra }) - if (isThenable(conditionResult)) { - conditionResult = await conditionResult - } - if (conditionResult === false) { - // eslint-disable-next-line no-throw-literal - throw { - name: 'ConditionError', - message: 'Aborted due to condition callback returning false.', + const promise = (async function () { + let finalAction: ReturnType + try { + let conditionResult = options?.condition?.(arg, { getState, extra }) + if (isThenable(conditionResult)) { + conditionResult = await conditionResult } - } - started = true - dispatch( - pending( - requestId, - arg, - options?.getPendingMeta?.({ requestId, arg }, { getState, extra }) - ) - ) - finalAction = await Promise.race([ - abortedPromise, - Promise.resolve( - payloadCreator(arg, { - dispatch, - getState, - extra, - requestId, - signal: abortController.signal, - abort, - rejectWithValue: (( - value: RejectedValue, - meta?: RejectedMeta - ) => { - return new RejectWithValue(value, meta) - }) as any, - fulfillWithValue: ((value: unknown, meta?: FulfilledMeta) => { - return new FulfillWithMeta(value, meta) - }) as any, - }) - ).then((result) => { - if (result instanceof RejectWithValue) { - throw result - } - if (result instanceof FulfillWithMeta) { - return fulfilled(result.payload, requestId, arg, result.meta) + if (conditionResult === false) { + // eslint-disable-next-line no-throw-literal + throw { + name: 'ConditionError', + message: 'Aborted due to condition callback returning false.', } - return fulfilled(result as any, requestId, arg) - }), - ]) - } catch (err) { - finalAction = - err instanceof RejectWithValue - ? rejected(null, requestId, arg, err.payload, err.meta) - : rejected(err as any, requestId, arg) - } - // We dispatch the result action _after_ the catch, to avoid having any errors - // here get swallowed by the try/catch block, - // per https://twitter.com/dan_abramov/status/770914221638942720 - // and https://github.com/reduxjs/redux-toolkit/blob/e85eb17b39a2118d859f7b7746e0f3fee523e089/docs/tutorials/advanced-tutorial.md#async-error-handling-logic-in-thunks - - const skipDispatch = - options && - !options.dispatchConditionRejection && - rejected.match(finalAction) && - (finalAction as any).meta.condition - - if (!skipDispatch) { - dispatch(finalAction) - } - return finalAction - })() - return Object.assign(promise as Promise, { - abort, - requestId, - arg, - unwrap() { - return promise.then(unwrapResult) - }, - }) + } + started = true + dispatch( + pending( + requestId, + arg, + options?.getPendingMeta?.( + { requestId, arg }, + { getState, extra } + ) + ) + ) + finalAction = await Promise.race([ + abortedPromise, + Promise.resolve( + payloadCreator(arg, { + dispatch, + getState, + extra, + requestId, + signal: abortController.signal, + abort, + rejectWithValue: (( + value: RejectedValue, + meta?: RejectedMeta + ) => { + return new RejectWithValue(value, meta) + }) as any, + fulfillWithValue: ((value: unknown, meta?: FulfilledMeta) => { + return new FulfillWithMeta(value, meta) + }) as any, + }) + ).then((result) => { + if (result instanceof RejectWithValue) { + throw result + } + if (result instanceof FulfillWithMeta) { + return fulfilled(result.payload, requestId, arg, result.meta) + } + return fulfilled(result as any, requestId, arg) + }), + ]) + } catch (err) { + finalAction = + err instanceof RejectWithValue + ? rejected(null, requestId, arg, err.payload, err.meta) + : rejected(err as any, requestId, arg) + } + // We dispatch the result action _after_ the catch, to avoid having any errors + // here get swallowed by the try/catch block, + // per https://twitter.com/dan_abramov/status/770914221638942720 + // and https://github.com/reduxjs/redux-toolkit/blob/e85eb17b39a2118d859f7b7746e0f3fee523e089/docs/tutorials/advanced-tutorial.md#async-error-handling-logic-in-thunks + + const skipDispatch = + options && + !options.dispatchConditionRejection && + rejected.match(finalAction) && + (finalAction as any).meta.condition + + if (!skipDispatch) { + dispatch(finalAction) + } + return finalAction + })() + return Object.assign(promise as Promise, { + abort, + requestId, + arg, + unwrap() { + return promise.then(unwrapResult) + }, + }) + } } + + return Object.assign( + actionCreator as AsyncThunkActionCreator< + Returned, + ThunkArg, + ThunkApiConfig + >, + { + pending, + rejected, + fulfilled, + typePrefix, + } + ) } + createAsyncThunk.withTypes = createAsyncThunk as unknown - return Object.assign( - actionCreator as AsyncThunkActionCreator< - Returned, - ThunkArg, - ThunkApiConfig - >, - { - pending, - rejected, - fulfilled, - typePrefix, - } - ) -} + return createAsyncThunk as CreateAsyncThunk +})() interface UnwrappableAction { payload: any diff --git a/packages/toolkit/src/tests/createAsyncThunk.typetest.ts b/packages/toolkit/src/tests/createAsyncThunk.typetest.ts index 98cfa93a1f..369b6b34af 100644 --- a/packages/toolkit/src/tests/createAsyncThunk.typetest.ts +++ b/packages/toolkit/src/tests/createAsyncThunk.typetest.ts @@ -1,17 +1,24 @@ /* eslint-disable no-lone-blocks */ import type { AnyAction, SerializedError, AsyncThunk } from '@reduxjs/toolkit' -import { createAsyncThunk, createReducer, unwrapResult } from '@reduxjs/toolkit' +import { + createAsyncThunk, + createReducer, + unwrapResult, + createSlice, + configureStore, +} from '@reduxjs/toolkit' import type { ThunkDispatch } from 'redux-thunk' import type { AxiosError } from 'axios' import apiRequest from 'axios' import type { IsAny, IsUnknown } from '@internal/tsHelpers' -import { expectType } from './helpers' +import { expectExactType, expectType } from './helpers' import type { AsyncThunkFulfilledActionCreator, AsyncThunkRejectedActionCreator, } from '@internal/createAsyncThunk' +const ANY = {} as any const defaultDispatch = (() => {}) as ThunkDispatch<{}, any, AnyAction> const anyAction = { type: 'foo' } as AnyAction @@ -590,3 +597,125 @@ const anyAction = { type: 'foo' } as AnyAction async (_, api) => api.rejectWithValue(5, '') ) } + +{ + const typedCAT = createAsyncThunk.withTypes<{ + state: RootState + dispatch: AppDispatch + rejectValue: string + extra: { s: string; n: number } + }>() + + // inferred usage + const thunk = typedCAT('foo', (arg: number, api) => { + // correct getState Type + const test1: number = api.getState().foo.value + // correct dispatch type + const test2: number = api.dispatch((dispatch, getState) => { + expectExactType< + ThunkDispatch<{ foo: { value: number } }, undefined, AnyAction> + >(ANY)(dispatch) + expectExactType<() => { foo: { value: number } }>(ANY)(getState) + return getState().foo.value + }) + + // correct extra type + const { s, n } = api.extra + expectExactType(s) + expectExactType(n) + + if (1 < 2) + // @ts-expect-error + return api.rejectWithValue(5) + if (1 < 2) return api.rejectWithValue('test') + return test1 + test2 + }) + + // usage with two generics + const thunk2 = typedCAT('foo', (arg, api) => { + expectExactType('' as string)(arg) + // correct getState Type + const test1: number = api.getState().foo.value + // correct dispatch type + const test2: number = api.dispatch((dispatch, getState) => { + expectExactType< + ThunkDispatch<{ foo: { value: number } }, undefined, AnyAction> + >(ANY)(dispatch) + expectExactType<() => { foo: { value: number } }>(ANY)(getState) + return getState().foo.value + }) + // correct extra type + const { s, n } = api.extra + expectExactType(s) + expectExactType(n) + + if (1 < 2) + // @ts-expect-error + return api.rejectWithValue(5) + if (1 < 2) return api.rejectWithValue('test') + return test1 + test2 + }) + + // usage with config override generic + const thunk3 = typedCAT( + 'foo', + (arg, api) => { + expectExactType('' as string)(arg) + // correct getState Type + const test1: number = api.getState().foo.value + // correct dispatch type + const test2: number = api.dispatch((dispatch, getState) => { + expectExactType< + ThunkDispatch<{ foo: { value: number } }, undefined, AnyAction> + >(ANY)(dispatch) + expectExactType<() => { foo: { value: number } }>(ANY)(getState) + return getState().foo.value + }) + // correct extra type + const { s, n } = api.extra + expectExactType(s) + expectExactType(n) + if (1 < 2) return api.rejectWithValue(5) + if (1 < 2) + // @ts-expect-error + return api.rejectWithValue('test') + return 5 + } + ) + + const slice = createSlice({ + name: 'foo', + initialState: { value: 0 }, + reducers: {}, + extraReducers(builder) { + builder + .addCase(thunk.fulfilled, (state, action) => { + state.value += action.payload + }) + .addCase(thunk.rejected, (state, action) => { + expectExactType('' as string | undefined)(action.payload) + }) + .addCase(thunk2.fulfilled, (state, action) => { + state.value += action.payload + }) + .addCase(thunk2.rejected, (state, action) => { + expectExactType('' as string | undefined)(action.payload) + }) + .addCase(thunk3.fulfilled, (state, action) => { + state.value += action.payload + }) + .addCase(thunk3.rejected, (state, action) => { + expectExactType(0 as number | undefined)(action.payload) + }) + }, + }) + + const store = configureStore({ + reducer: { + foo: slice.reducer, + }, + }) + + type RootState = ReturnType + type AppDispatch = typeof store.dispatch +} diff --git a/packages/toolkit/src/tsHelpers.ts b/packages/toolkit/src/tsHelpers.ts index 2e9f75c592..916f0dbd26 100644 --- a/packages/toolkit/src/tsHelpers.ts +++ b/packages/toolkit/src/tsHelpers.ts @@ -139,3 +139,5 @@ export type ActionFromMatcher> = M extends Matcher< > ? T : never + +export type Id = { [K in keyof T]: T[K] } & {}