UNPKG

@reduxjs/toolkit

Version:

The official, opinionated, batteries-included toolset for efficient Redux development

876 lines (789 loc) 24 kB
import type { Action, UnknownAction, Reducer } from 'redux' import type { ActionCreatorWithNonInferrablePayload, ActionCreatorWithOptionalPayload, ActionCreatorWithoutPayload, ActionCreatorWithPayload, ActionCreatorWithPreparedPayload, ActionReducerMapBuilder, AsyncThunk, CaseReducer, PayloadAction, PayloadActionCreator, ReducerCreators, SerializedError, SliceCaseReducers, ThunkDispatch, ValidateSliceCaseReducers, } from '@reduxjs/toolkit' import { configureStore, isRejected, createAction, createSlice, buildCreateSlice, asyncThunkCreator, createAsyncThunk, } from '@reduxjs/toolkit' import { expectExactType, expectType, expectUnknown } from './helpers' import { castDraft } from 'immer' /* * Test: Slice name is strongly typed. */ const counterSlice = createSlice({ name: 'counter', initialState: 0, reducers: { increment: (state: number, action) => state + action.payload, decrement: (state: number, action) => state - action.payload, }, }) const uiSlice = createSlice({ name: 'ui', initialState: 0, reducers: { goToNext: (state: number, action) => state + action.payload, goToPrevious: (state: number, action) => state - action.payload, }, }) const actionCreators = { [counterSlice.name]: { ...counterSlice.actions }, [uiSlice.name]: { ...uiSlice.actions }, } expectType<typeof counterSlice.actions>(actionCreators.counter) expectType<typeof uiSlice.actions>(actionCreators.ui) // @ts-expect-error const value = actionCreators.anyKey /* * Test: createSlice() infers the returned slice's type. */ { const firstAction = createAction<{ count: number }>('FIRST_ACTION') const slice = createSlice({ name: 'counter', initialState: 0, reducers: { increment: (state: number, action) => state + action.payload, decrement: (state: number, action) => state - action.payload, }, extraReducers: (builder) => { builder.addCase( firstAction, (state, action) => state + action.payload.count ) }, }) /* Reducer */ expectType<Reducer<number, PayloadAction>>(slice.reducer) // @ts-expect-error expectType<Reducer<string, PayloadAction>>(slice.reducer) // @ts-expect-error expectType<Reducer<string, UnknownAction>>(slice.reducer) /* Actions */ slice.actions.increment(1) slice.actions.decrement(1) // @ts-expect-error slice.actions.other(1) } /* * Test: Slice action creator types are inferred. */ { const counter = createSlice({ name: 'counter', initialState: 0, reducers: { increment: (state) => state + 1, decrement: (state, { payload = 1 }: PayloadAction<number | undefined>) => state - payload, multiply: (state, { payload }: PayloadAction<number | number[]>) => Array.isArray(payload) ? payload.reduce((acc, val) => acc * val, state) : state * payload, addTwo: { reducer: (s, { payload }: PayloadAction<number>) => s + payload, prepare: (a: number, b: number) => ({ payload: a + b, }), }, }, }) expectType<ActionCreatorWithoutPayload>(counter.actions.increment) counter.actions.increment() expectType<ActionCreatorWithOptionalPayload<number | undefined>>( counter.actions.decrement ) counter.actions.decrement() counter.actions.decrement(2) expectType<ActionCreatorWithPayload<number | number[]>>( counter.actions.multiply ) counter.actions.multiply(2) counter.actions.multiply([2, 3, 4]) expectType<ActionCreatorWithPreparedPayload<[number, number], number>>( counter.actions.addTwo ) counter.actions.addTwo(1, 2) // @ts-expect-error counter.actions.multiply() // @ts-expect-error counter.actions.multiply('2') // @ts-expect-error counter.actions.addTwo(1) } /* * Test: Slice action creator types properties are "string" */ { const counter = createSlice({ name: 'counter', initialState: 0, reducers: { increment: (state) => state + 1, decrement: (state) => state - 1, multiply: (state, { payload }: PayloadAction<number | number[]>) => Array.isArray(payload) ? payload.reduce((acc, val) => acc * val, state) : state * payload, }, }) const s: 'counter/increment' = counter.actions.increment.type const sa: 'counter/increment' = counter.actions.increment().type const t: 'counter/decrement' = counter.actions.decrement.type const ta: 'counter/decrement' = counter.actions.decrement().type const u: 'counter/multiply' = counter.actions.multiply.type const ua: 'counter/multiply' = counter.actions.multiply(1).type // @ts-expect-error const y: 'increment' = counter.actions.increment.type } /* * Test: Slice action creator types are inferred for enhanced reducers. */ { const counter = createSlice({ name: 'test', initialState: { counter: 0, concat: '' }, reducers: { incrementByStrLen: { reducer: (state, action: PayloadAction<number>) => { state.counter += action.payload }, prepare: (payload: string) => ({ payload: payload.length, }), }, concatMetaStrLen: { reducer: (state, action: PayloadAction<string>) => { state.concat += action.payload }, prepare: (payload: string) => ({ payload, meta: payload.length, }), }, }, }) expectType<'test/incrementByStrLen'>( counter.actions.incrementByStrLen('test').type ) expectType<number>(counter.actions.incrementByStrLen('test').payload) expectType<string>(counter.actions.concatMetaStrLen('test').payload) expectType<number>(counter.actions.concatMetaStrLen('test').meta) // @ts-expect-error expectType<string>(counter.actions.incrementByStrLen('test').payload) // @ts-expect-error expectType<string>(counter.actions.concatMetaStrLen('test').meta) } /** * Test: access meta and error from reducer */ { const counter = createSlice({ name: 'test', initialState: { counter: 0, concat: '' }, reducers: { // case: meta and error not used in reducer testDefaultMetaAndError: { reducer(_, action: PayloadAction<number, string>) {}, prepare: (payload: number) => ({ payload, meta: 'meta' as 'meta', error: 'error' as 'error', }), }, // case: meta and error marked as "unknown" in reducer testUnknownMetaAndError: { reducer(_, action: PayloadAction<number, string, unknown, unknown>) {}, prepare: (payload: number) => ({ payload, meta: 'meta' as 'meta', error: 'error' as 'error', }), }, // case: meta and error are typed in the reducer as returned by prepare testMetaAndError: { reducer(_, action: PayloadAction<number, string, 'meta', 'error'>) {}, prepare: (payload: number) => ({ payload, meta: 'meta' as 'meta', error: 'error' as 'error', }), }, // case: meta is typed differently in the reducer than returned from prepare testErroneousMeta: { reducer(_, action: PayloadAction<number, string, 'meta', 'error'>) {}, // @ts-expect-error prepare: (payload: number) => ({ payload, meta: 1, error: 'error' as 'error', }), }, // case: error is typed differently in the reducer than returned from prepare testErroneousError: { reducer(_, action: PayloadAction<number, string, 'meta', 'error'>) {}, // @ts-expect-error prepare: (payload: number) => ({ payload, meta: 'meta' as 'meta', error: 1, }), }, }, }) } /* * Test: returned case reducer has the correct type */ { const counter = createSlice({ name: 'counter', initialState: 0, reducers: { increment(state, action: PayloadAction<number>) { return state + action.payload }, decrement: { reducer(state, action: PayloadAction<number>) { return state - action.payload }, prepare(amount: number) { return { payload: amount } }, }, }, }) // Should match positively expectType<(state: number, action: PayloadAction<number>) => number | void>( counter.caseReducers.increment ) // Should match positively for reducers with prepare callback expectType<(state: number, action: PayloadAction<number>) => number | void>( counter.caseReducers.decrement ) // Should not mismatch the payload if it's a simple reducer expectType<(state: number, action: PayloadAction<string>) => number | void>( // @ts-expect-error counter.caseReducers.increment ) // Should not mismatch the payload if it's a reducer with a prepare callback expectType<(state: number, action: PayloadAction<string>) => number | void>( // @ts-expect-error counter.caseReducers.decrement ) // Should not include entries that don't exist expectType<(state: number, action: PayloadAction<string>) => number | void>( // @ts-expect-error counter.caseReducers.someThingNonExistant ) } /* * Test: prepared payload does not match action payload - should cause an error. */ { const counter = createSlice({ name: 'counter', initialState: { counter: 0 }, reducers: { increment: { reducer(state, action: PayloadAction<string>) { state.counter += action.payload.length }, // @ts-expect-error prepare(x: string) { return { payload: 6, } }, }, }, }) } /* * Test: if no Payload Type is specified, accept any payload * see https://github.com/reduxjs/redux-toolkit/issues/165 */ { const initialState = { name: null, } const mySlice = createSlice({ name: 'name', initialState, reducers: { setName: (state, action) => { state.name = action.payload }, }, }) expectType<ActionCreatorWithNonInferrablePayload>(mySlice.actions.setName) const x = mySlice.actions.setName mySlice.actions.setName(null) mySlice.actions.setName('asd') mySlice.actions.setName(5) } /** * Test: actions.x.match() */ { const mySlice = createSlice({ name: 'name', initialState: { name: 'test' }, reducers: { setName: (state, action: PayloadAction<string>) => { state.name = action.payload }, }, }) const x: Action<string> = {} as any if (mySlice.actions.setName.match(x)) { expectType<'name/setName'>(x.type) expectType<string>(x.payload) } else { // @ts-expect-error expectType<'name/setName'>(x.type) // @ts-expect-error expectType<string>(x.payload) } } /** Test: builder callback for extraReducers */ { createSlice({ name: 'test', initialState: 0, reducers: {}, extraReducers: (builder) => { expectType<ActionReducerMapBuilder<number>>(builder) }, }) } /** Test: wrapping createSlice should be possible */ { interface GenericState<T> { data?: T status: 'loading' | 'finished' | 'error' } const createGenericSlice = < T, Reducers extends SliceCaseReducers<GenericState<T>> >({ name = '', initialState, reducers, }: { name: string initialState: GenericState<T> reducers: ValidateSliceCaseReducers<GenericState<T>, Reducers> }) => { return createSlice({ name, initialState, reducers: { start(state) { state.status = 'loading' }, success(state: GenericState<T>, action: PayloadAction<T>) { state.data = action.payload state.status = 'finished' }, ...reducers, }, }) } const wrappedSlice = createGenericSlice({ name: 'test', initialState: { status: 'loading' } as GenericState<string>, reducers: { magic(state) { expectType<GenericState<string>>(state) // @ts-expect-error expectType<GenericState<number>>(state) state.status = 'finished' state.data = 'hocus pocus' }, }, }) expectType<ActionCreatorWithPayload<string>>(wrappedSlice.actions.success) expectType<ActionCreatorWithoutPayload<string>>(wrappedSlice.actions.magic) } { interface GenericState<T> { data: T | null } function createDataSlice< T, Reducers extends SliceCaseReducers<GenericState<T>> >( name: string, reducers: ValidateSliceCaseReducers<GenericState<T>, Reducers>, initialState: GenericState<T> ) { const doNothing = createAction<undefined>('doNothing') const setData = createAction<T>('setData') const slice = createSlice({ name, initialState, reducers, extraReducers: (builder) => { builder.addCase(doNothing, (state) => { return { ...state } }) builder.addCase(setData, (state, { payload }) => { return { ...state, data: payload, } }) }, }) return { doNothing, setData, slice } } } /** * Test: slice selectors */ { const sliceWithoutSelectors = createSlice({ name: '', initialState: '', reducers: {}, }) // @ts-expect-error sliceWithoutSelectors.selectors.foo const sliceWithSelectors = createSlice({ name: 'counter', initialState: { value: 0 }, reducers: { increment: (state) => { state.value += 1 }, }, selectors: { selectValue: (state) => state.value, selectMultiply: (state, multiplier: number) => state.value * multiplier, selectToFixed: Object.assign( (state: { value: number }) => state.value.toFixed(2), { static: true } ), }, }) const rootState = { [sliceWithSelectors.reducerPath]: sliceWithSelectors.getInitialState(), } const { selectValue, selectMultiply, selectToFixed } = sliceWithSelectors.selectors expectType<number>(selectValue(rootState)) expectType<number>(selectMultiply(rootState, 2)) expectType<string>(selectToFixed(rootState)) expectType<boolean>(selectToFixed.unwrapped.static) const nestedState = { nested: rootState, } const nestedSelectors = sliceWithSelectors.getSelectors( (rootState: typeof nestedState) => rootState.nested.counter ) expectType<number>(nestedSelectors.selectValue(nestedState)) expectType<number>(nestedSelectors.selectMultiply(nestedState, 2)) expectType<string>(nestedSelectors.selectToFixed(nestedState)) } /** * Test: reducer callback */ { interface TestState { foo: string } interface TestArg { test: string } interface TestReturned { payload: string } interface TestReject { cause: string } const slice = createSlice({ name: 'test', initialState: {} as TestState, reducers: (create) => { const pretypedAsyncThunk = create.asyncThunk.withTypes<{ rejectValue: TestReject }>() // @ts-expect-error create.asyncThunk<any, any, { state: StoreState }>(() => {}) // @ts-expect-error create.asyncThunk.withTypes<{ rejectValue: string dispatch: StoreDispatch }>() return { normalReducer: create.reducer<string>((state, action) => { expectType<TestState>(state) expectType<string>(action.payload) }), optionalReducer: create.reducer<string | undefined>((state, action) => { expectType<TestState>(state) expectType<string | undefined>(action.payload) }), noActionReducer: create.reducer((state) => { expectType<TestState>(state) }), preparedReducer: create.preparedReducer( (payload: string) => ({ payload, meta: 'meta' as const, error: 'error' as const, }), (state, action) => { expectType<TestState>(state) expectType<string>(action.payload) expectExactType('meta' as const)(action.meta) expectExactType('error' as const)(action.error) } ), testInfer: create.asyncThunk( function payloadCreator(arg: TestArg, api) { return Promise.resolve<TestReturned>({ payload: 'foo' }) }, { pending(state, action) { expectType<TestState>(state) expectType<TestArg>(action.meta.arg) }, fulfilled(state, action) { expectType<TestState>(state) expectType<TestArg>(action.meta.arg) expectType<TestReturned>(action.payload) }, rejected(state, action) { expectType<TestState>(state) expectType<TestArg>(action.meta.arg) expectType<SerializedError>(action.error) }, settled(state, action) { expectType<TestState>(state) expectType<TestArg>(action.meta.arg) if (isRejected(action)) { expectType<SerializedError>(action.error) } else { expectType<TestReturned>(action.payload) } }, } ), testExplicitType: create.asyncThunk< TestReturned, TestArg, { rejectValue: TestReject } >( function payloadCreator(arg, api) { // here would be a circular reference expectUnknown(api.getState()) // here would be a circular reference expectType<ThunkDispatch<any, any, any>>(api.dispatch) // so you need to cast inside instead const getState = api.getState as () => StoreState const dispatch = api.dispatch as StoreDispatch expectType<TestArg>(arg) expectType<(value: TestReject) => any>(api.rejectWithValue) return Promise.resolve({ payload: 'foo' }) }, { pending(state, action) { expectType<TestState>(state) expectType<TestArg>(action.meta.arg) }, fulfilled(state, action) { expectType<TestState>(state) expectType<TestArg>(action.meta.arg) expectType<TestReturned>(action.payload) }, rejected(state, action) { expectType<TestState>(state) expectType<TestArg>(action.meta.arg) expectType<SerializedError>(action.error) expectType<TestReject | undefined>(action.payload) }, settled(state, action) { expectType<TestState>(state) expectType<TestArg>(action.meta.arg) if (isRejected(action)) { expectType<SerializedError>(action.error) expectType<TestReject | undefined>(action.payload) } else { expectType<TestReturned>(action.payload) } }, } ), testPretyped: pretypedAsyncThunk( function payloadCreator(arg: TestArg, api) { expectType<(value: TestReject) => any>(api.rejectWithValue) return Promise.resolve<TestReturned>({ payload: 'foo' }) }, { pending(state, action) { expectType<TestState>(state) expectType<TestArg>(action.meta.arg) }, fulfilled(state, action) { expectType<TestState>(state) expectType<TestArg>(action.meta.arg) expectType<TestReturned>(action.payload) }, rejected(state, action) { expectType<TestState>(state) expectType<TestArg>(action.meta.arg) expectType<SerializedError>(action.error) expectType<TestReject | undefined>(action.payload) }, settled(state, action) { expectType<TestState>(state) expectType<TestArg>(action.meta.arg) if (isRejected(action)) { expectType<SerializedError>(action.error) expectType<TestReject | undefined>(action.payload) } else { expectType<TestReturned>(action.payload) } }, } ), } }, }) const store = configureStore({ reducer: { test: slice.reducer } }) type StoreState = ReturnType<typeof store.getState> type StoreDispatch = typeof store.dispatch expectType<PayloadActionCreator<string>>(slice.actions.normalReducer) slice.actions.normalReducer('') // @ts-expect-error slice.actions.normalReducer() // @ts-expect-error slice.actions.normalReducer(0) expectType<ActionCreatorWithOptionalPayload<string | undefined>>( slice.actions.optionalReducer ) slice.actions.optionalReducer() slice.actions.optionalReducer('') // @ts-expect-error slice.actions.optionalReducer(0) expectType<ActionCreatorWithoutPayload>(slice.actions.noActionReducer) slice.actions.noActionReducer() // @ts-expect-error slice.actions.noActionReducer('') expectType< ActionCreatorWithPreparedPayload< [string], string, 'test/preparedReducer', 'error', 'meta' > >(slice.actions.preparedReducer) expectType<AsyncThunk<TestReturned, TestArg, {}>>(slice.actions.testInfer) expectType<AsyncThunk<TestReturned, TestArg, { rejectValue: TestReject }>>( slice.actions.testExplicitType ) { type TestInferThunk = AsyncThunk<TestReturned, TestArg, {}> expectType<CaseReducer<TestState, ReturnType<TestInferThunk['pending']>>>( slice.caseReducers.testInfer.pending ) expectType<CaseReducer<TestState, ReturnType<TestInferThunk['fulfilled']>>>( slice.caseReducers.testInfer.fulfilled ) expectType<CaseReducer<TestState, ReturnType<TestInferThunk['rejected']>>>( slice.caseReducers.testInfer.rejected ) } } /** Test: wrapping createSlice should be possible, with callback */ { interface GenericState<T> { data?: T status: 'loading' | 'finished' | 'error' } const createGenericSlice = < T, Reducers extends SliceCaseReducers<GenericState<T>> >({ name = '', initialState, reducers, }: { name: string initialState: GenericState<T> reducers: (create: ReducerCreators<GenericState<T>>) => Reducers }) => { return createSlice({ name, initialState, reducers: (create) => ({ start: create.reducer((state) => { state.status = 'loading' }), success: create.reducer<T>((state, action) => { state.data = castDraft(action.payload) state.status = 'finished' }), ...reducers(create), }), }) } const wrappedSlice = createGenericSlice({ name: 'test', initialState: { status: 'loading' } as GenericState<string>, reducers: (create) => ({ magic: create.reducer((state) => { expectType<GenericState<string>>(state) // @ts-expect-error expectType<GenericState<number>>(state) state.status = 'finished' state.data = 'hocus pocus' }), }), }) expectType<ActionCreatorWithPayload<string>>(wrappedSlice.actions.success) expectType<ActionCreatorWithoutPayload<string>>(wrappedSlice.actions.magic) } /** * Test: selectSlice */ { expectType<number>(counterSlice.selectSlice({ counter: 0 })) // @ts-expect-error counterSlice.selectSlice({}) } /** * Test: buildCreateSlice */ { expectExactType(createSlice)(buildCreateSlice()) buildCreateSlice({ // @ts-expect-error not possible to recreate shape because symbol is not exported creators: { asyncThunk: { [Symbol()]: createAsyncThunk } }, }) buildCreateSlice({ creators: { asyncThunk: asyncThunkCreator } }) }