Skip to content

Commit

Permalink
Improve type inference of case reducers (#133)
Browse files Browse the repository at this point in the history
* Improve type inference of case reducers

Previously, the TypeScript compiler would reject case reducer maps
with different incompatible PayloadAction types. The case reducers
map and createReducer() / createSlice() types have now been
restructured to allow for better type inference.

Fixes #131

* Upgrade TypeScript and ESLint parser

Upgraded TypeScript to 3.4.3 and switches to the new TypeScript ESLint
parser.
  • Loading branch information
denisw authored and markerikson committed Apr 28, 2019
1 parent 82014e0 commit 4da8c59
Show file tree
Hide file tree
Showing 7 changed files with 80 additions and 88 deletions.
2 changes: 1 addition & 1 deletion .eslintrc.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
module.exports = {
extends: 'react-app',
parser: 'typescript-eslint-parser',
parser: '@typescript-eslint/parser',

rules: {
'jsx-a11y/href-no-hash': 'off',
Expand Down
87 changes: 44 additions & 43 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"@types/jest": "^23.3.12",
"@types/node": "^10.12.18",
"@types/redux-immutable-state-invariant": "^2.1.0",
"@typescript-eslint/parser": "^1.6.0",
"babel-core": "7.0.0-bridge.0",
"babel-eslint": "^10.0.1",
"eslint": "^4.17.0",
Expand All @@ -30,8 +31,7 @@
"rollup-plugin-babel": "^4.2.0",
"rollup-plugin-commonjs": "^9.2.0",
"rollup-plugin-node-resolve": "^4.0.0",
"typescript": "^3.2.2",
"typescript-eslint-parser": "eslint/typescript-eslint-parser",
"typescript": "^3.4.3",
"typings-tester": "^0.3.2"
},
"scripts": {
Expand Down
19 changes: 12 additions & 7 deletions src/createReducer.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import createNextState, { Draft } from 'immer'
import { AnyAction, Action, Reducer } from 'redux'

/**
* Defines a mapping from action types to corresponding action object shapes.
*/
export type Actions<T extends keyof any = string> = Record<T, Action>

/**
* An *case reducer* is a reducer function for a speficic action type. Case
* reducers can be composed to full reducers using `createReducer()`.
Expand All @@ -23,8 +28,8 @@ export type CaseReducer<S = any, A extends Action = AnyAction> = (
/**
* A mapping from action types to case reducers for `createReducer()`.
*/
export interface CaseReducersMapObject<S = any, A extends Action = AnyAction> {
[actionType: string]: CaseReducer<S, A>
export type CaseReducers<S, AS extends Actions> = {
[T in keyof AS]: AS[T] extends Action ? CaseReducer<S, AS[T]> : void
}

/**
Expand All @@ -43,17 +48,17 @@ export interface CaseReducersMapObject<S = any, A extends Action = AnyAction> {
* @param actionsMap A mapping from action types to action-type-specific
* case redeucers.
*/
export function createReducer<S = any, A extends Action = AnyAction>(
initialState: S,
actionsMap: CaseReducersMapObject<S, A>
): Reducer<S> {
export function createReducer<
S,
CR extends CaseReducers<S, any> = CaseReducers<S, any>
>(initialState: S, actionsMap: CR): Reducer<S> {
return function(state = initialState, action): S {
// @ts-ignore createNextState() produces an Immutable<Draft<S>> rather
// than an Immutable<S>, and TypeScript cannot find out how to reconcile
// these two types.
return createNextState(state, (draft: Draft<S>) => {
const caseReducer = actionsMap[action.type]
return caseReducer ? caseReducer(draft, action as A) : undefined
return caseReducer ? caseReducer(draft, action) : undefined
})
}
}
5 changes: 3 additions & 2 deletions src/createSlice.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import { createSlice } from './createSlice'
import { createAction } from './createAction'
import { createAction, PayloadAction } from './createAction'

describe('createSlice', () => {
describe('when slice is empty', () => {
const { actions, reducer, selectors } = createSlice({
reducers: {
increment: state => state + 1,
multiply: (state, action) => state * action.payload
multiply: (state, action: PayloadAction<number>) =>
state * action.payload
},
initialState: 0
})
Expand Down
39 changes: 15 additions & 24 deletions src/createSlice.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,17 @@
import { Action, AnyAction, Reducer } from 'redux'
import { Reducer } from 'redux'
import { createAction, PayloadAction } from './createAction'
import { createReducer, CaseReducersMapObject } from './createReducer'
import { createReducer, CaseReducers } from './createReducer'
import { createSliceSelector, createSelectorName } from './sliceSelector'

/**
* An action creator atttached to a slice.
*/
export type SliceActionCreator<P> = (payload: P) => PayloadAction<P>
export type SliceActionCreator<P> = P extends void
? () => PayloadAction<void>
: (payload: P) => PayloadAction<P>

export interface Slice<
S = any,
A extends Action = AnyAction,
AP extends { [key: string]: any } = { [key: string]: any }
> {
/**
Expand All @@ -21,7 +22,7 @@ export interface Slice<
/**
* The slice's reducer.
*/
reducer: Reducer<S, A>
reducer: Reducer<S>

/**
* Action creators for the types of actions that are handled by the slice
Expand All @@ -43,9 +44,7 @@ export interface Slice<
*/
export interface CreateSliceOptions<
S = any,
A extends Action = AnyAction,
CR extends CaseReducersMapObject<S, A> = CaseReducersMapObject<S, A>,
CR2 extends CaseReducersMapObject<S, A> = CaseReducersMapObject<S, A>
CR extends CaseReducers<S, any> = CaseReducers<S, any>
> {
/**
* The slice's name. Used to namespace the generated action types and to
Expand All @@ -70,19 +69,15 @@ export interface CreateSliceOptions<
* functions. These reducers should have existing action types used
* as the keys, and action creators will _not_ be generated.
*/
extraReducers?: CR2
extraReducers?: CaseReducers<S, any>
}

type ExtractPayloads<
S,
A extends PayloadAction,
CR extends CaseReducersMapObject<S, A>
> = {
[type in keyof CR]: CR[type] extends (state: S) => any
type CaseReducerActionPayloads<CR extends CaseReducers<any, any>> = {
[T in keyof CR]: CR[T] extends (state: any) => any
? void
: (CR[type] extends (state: S, action: PayloadAction<infer P>) => any
: (CR[T] extends (state: any, action: PayloadAction<infer P>) => any
? P
: never)
: void)
}

function getType(slice: string, actionKey: string): string {
Expand All @@ -97,13 +92,9 @@ function getType(slice: string, actionKey: string): string {
*
* The `reducer` argument is passed to `createReducer()`.
*/
export function createSlice<
S = any,
A extends PayloadAction = PayloadAction<any>,
CR extends CaseReducersMapObject<S, A> = CaseReducersMapObject<S, A>
>(
options: CreateSliceOptions<S, A, CR>
): Slice<S, A, ExtractPayloads<S, A, CR>> {
export function createSlice<S, CR extends CaseReducers<S, any>>(
options: CreateSliceOptions<S, CR>
): Slice<S, CaseReducerActionPayloads<CR>> {
const { slice = '', initialState } = options
const reducers = options.reducers || {}
const extraReducers = options.extraReducers || {}
Expand Down
12 changes: 3 additions & 9 deletions type-tests/files/createReducer.typetest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import { AnyAction, createReducer, Reducer } from 'redux-starter-kit'
}

/**
* Test: createReducer() type parameters can be specified expliclity.
* Test: createReducer() state type can be specified expliclity.
*/
{
type CounterAction =
Expand All @@ -36,19 +36,13 @@ import { AnyAction, createReducer, Reducer } from 'redux-starter-kit'
const decrementHandler = (state: number, action: CounterAction) =>
state - action.payload

createReducer<number, CounterAction>(0, {
createReducer<number>(0, {
increment: incrementHandler,
decrement: decrementHandler
})

// typings:expect-error
createReducer<string, CounterAction>(0, {
increment: incrementHandler,
decrement: decrementHandler
})

// typings:expect-error
createReducer<number, AnyAction>(0, {
createReducer<string>(0, {
increment: incrementHandler,
decrement: decrementHandler
})
Expand Down

0 comments on commit 4da8c59

Please sign in to comment.