Skip to content

Commit

Permalink
#2979 Add strict type inference overload for combineReducers. (#3484)
Browse files Browse the repository at this point in the history
* Add type overload for combineReducers which strictly infers state shape and actions from the reducers object map.

* Fixed some typos.

* Please don't change version numbers in a PR

* Typescript 2.8 default type fixes.
  • Loading branch information
Shakeskeyboarde authored and timdorr committed Aug 10, 2019
1 parent 6afef6a commit 63dda81
Show file tree
Hide file tree
Showing 2 changed files with 104 additions and 18 deletions.
47 changes: 47 additions & 0 deletions index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,50 @@ export type ReducersMapObject<S = any, A extends Action = Action> = {
[K in keyof S]: Reducer<S[K], A>
}

/**
* Infer a combined state shape from a `ReducersMapObject`.
*
* @template M Object map of reducers as provided to `combineReducers(map: M)`.
*/
export type StateFromReducersMapObject<M> = M extends ReducersMapObject<
any,
any
>
? { [P in keyof M]: M[P] extends Reducer<infer S, any> ? S : never }
: never

/**
* Infer reducer union type from a `ReducersMapObject`.
*
* @template M Object map of reducers as provided to `combineReducers(map: M)`.
*/
export type ReducerFromReducersMapObject<M> = M extends {
[P in keyof M]: infer R
}
? R extends Reducer<any, any>
? R
: never
: never

/**
* Infer action type from a reducer function.
*
* @template R Type of reducer.
*/
export type ActionFromReducer<R> = R extends Reducer<any, infer A> ? A : never

/**
* Infer action union type from a `ReducersMapObject`.
*
* @template M Object map of reducers as provided to `combineReducers(map: M)`.
*/
export type ActionFromReducersMapObject<M> = M extends ReducersMapObject<
any,
any
>
? ActionFromReducer<ReducerFromReducersMapObject<M>>
: never

/**
* Turns an object whose values are different reducer functions, into a single
* reducer function. It will call every child reducer, and gather their results
Expand All @@ -96,6 +140,9 @@ export function combineReducers<S>(
export function combineReducers<S, A extends Action = AnyAction>(
reducers: ReducersMapObject<S, A>
): Reducer<S, A>
export function combineReducers<M extends ReducersMapObject<any, any>>(
reducers: M
): Reducer<StateFromReducersMapObject<M>, ActionFromReducersMapObject<M>>

/* store */

Expand Down
75 changes: 57 additions & 18 deletions test/typescript/reducers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,10 +68,21 @@ function discriminated() {
count?: number
}

interface MultiplyAction {
type: 'MULTIPLY'
count?: number
}

interface DivideAction {
type: 'DIVIDE'
count?: number
}

// Union of all actions in the app.
type MyAction = IncrementAction | DecrementAction
type MyAction0 = IncrementAction | DecrementAction
type MyAction1 = MultiplyAction | DivideAction

const reducer: Reducer<State, MyAction> = (state = 0, action) => {
const reducer0: Reducer<State, MyAction0> = (state = 0, action) => {
if (action.type === 'INCREMENT') {
// Action shape is determined by `type` discriminator.
// typings:expect-error
Expand All @@ -94,37 +105,65 @@ function discriminated() {
return state
}

const reducer1: Reducer<State, MyAction1> = (state = 0, action) => {
if (action.type === 'MULTIPLY') {
// typings:expect-error
action.wrongField

const { count = 1 } = action

return state * count
}

if (action.type === 'DIVIDE') {
// typings:expect-error
action.wrongField

const { count = 1 } = action

return state / count
}

return state
}

// Reducer state is initialized by Redux using Init action which is private.
// To initialize manually (e.g. in tests) we have to type cast init action
// or add a custom init action to MyAction union.
let s: State = reducer(undefined, { type: 'init' } as any)
s = reducer(s, { type: 'INCREMENT' })
s = reducer(s, { type: 'INCREMENT', count: 10 })
let s: State = reducer0(undefined, { type: 'init' } as any)
s = reducer0(s, { type: 'INCREMENT' })
s = reducer0(s, { type: 'INCREMENT', count: 10 })
// Known actions are strictly checked.
// typings:expect-error
s = reducer(s, { type: 'DECREMENT', coun: 10 })
s = reducer(s, { type: 'DECREMENT', count: 10 })
s = reducer0(s, { type: 'DECREMENT', coun: 10 })
s = reducer0(s, { type: 'DECREMENT', count: 10 })
// Unknown actions are rejected.
// typings:expect-error
s = reducer(s, { type: 'SOME_OTHER_TYPE' })
s = reducer0(s, { type: 'SOME_OTHER_TYPE' })
// typings:expect-error
s = reducer(s, { type: 'SOME_OTHER_TYPE', someField: 'value' })
s = reducer0(s, { type: 'SOME_OTHER_TYPE', someField: 'value' })

// Combined reducer accepts any action by default which allows to include
// third-party reducers without the need to add their actions to the union.
const combined = combineReducers({ sub: reducer })
// Combined reducer infers state and actions by default which maintains type
// safety and still allows inclusion of third-party reducers without the need
// to explicitly add their state and actions to the union.
const combined = combineReducers({ sub0: reducer0, sub1: reducer1 })

let cs: { sub: State } = combined(undefined, { type: 'init' })
cs = combined(cs, { type: 'SOME_OTHER_TYPE' })
const cs = combined(undefined, { type: 'INCREMENT' })
combined(cs, { type: 'MULTIPLY' })
// typings:expect-error
combined(cs, { type: 'init' })
// typings:expect-error
combined(cs, { type: 'SOME_OTHER_TYPE' })

// Combined reducer can be made to only accept known actions.
const strictCombined = combineReducers<{ sub: State }, MyAction>({
sub: reducer
const strictCombined = combineReducers<{ sub: State }, MyAction0>({
sub: reducer0
})

strictCombined(cs, { type: 'INCREMENT' })
const scs = strictCombined(undefined, { type: 'INCREMENT' })
strictCombined(scs, { type: 'DECREMENT' })
// typings:expect-error
strictCombined(cs, { type: 'SOME_OTHER_TYPE' })
strictCombined(scs, { type: 'SOME_OTHER_TYPE' })
}

/**
Expand Down

0 comments on commit 63dda81

Please sign in to comment.