Last active
January 25, 2019 00:16
-
-
Save ferdaber/2241b88fb602a39e680021a48f311064 to your computer and use it in GitHub Desktop.
Strongly typed Redux
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// definitions | |
import { Middleware } from 'redux' | |
export declare const dispatch: Dispatch | |
export type State = unknown | |
/** | |
* General action type, DO NOT USE AS A RETURN TYPE | |
*/ | |
interface Action<TType extends string = string, TPayload = any> { | |
type: TType | |
payload?: TPayload | |
} | |
/** | |
* General action creator type, DO NOT USE AS A RETURN TYPE | |
*/ | |
interface ActionCreator< | |
TType extends string = string, | |
TAction extends Action<TType> = { type: TType } | |
> { | |
type: TType | |
create(...args: any[]): TAction | |
} | |
/** | |
* Call signature of the dispatch function, which is more restrictive than the | |
* DispatchedAction type, but essentially the same, in function form | |
*/ | |
interface Dispatch { | |
<TAction extends Action>(action: TAction): DispatchedAction<TAction> | |
<TReturn>(thunk: (dispatch: Dispatch, getState: () => State) => TReturn): TReturn | |
} | |
/** | |
* Given an actual action object, resolves the return type | |
* when this action object is dispatched | |
*/ | |
type DispatchedAction<TAction> = TAction extends { type: infer TType } | |
? TType extends string | |
? TAction extends { payload: Promise<any> } | |
? PendingPromiseAction<TAction> | |
: TAction | |
: never | |
: TAction extends Thunk<infer TReturn> | |
? TReturn | |
: never | |
/** | |
* Call signature of a thunk, which is passed the dispatch and getState APIs | |
*/ | |
type Thunk<TReturn> = (dispatch: Dispatch, getState: () => State) => TReturn | |
/** | |
* The type of a dispatched action when it has a promise as a payload | |
* This one is immediately dispatched after going through the middleware | |
*/ | |
type PendingPromiseAction<TAction extends Action<any, Promise<any>>> = { | |
status: 'pending' | |
payload: TAction['payload'] | |
type: TAction['type'] | |
promise: TAction['payload'] | |
} | |
/** | |
* The type of a dispatched action when it has a promise as a payload | |
* This one is dispatched when the promise is fulfilled | |
*/ | |
type FulfilledPromiseAction<TAction extends Action<any, Promise<any>>> = { | |
status: 'fulfilled' | |
payload: PromiseValue<TAction['payload']> | |
type: TAction['type'] | |
promise: TAction['payload'] | |
} | |
/** | |
* The type of a dispatched action when it has a promise as a payload | |
* This one is dispatched when the promise is rejected | |
*/ | |
type RejectedPromiseAction<TAction extends Action<any, Promise<any>>> = { | |
status: 'rejected' | |
payload: Error | |
type: TAction['type'] | |
promise: TAction['payload'] | |
} | |
/** | |
* @param type The action type, should be a string literal | |
* @param createPayload The payload creator if one exists for the action | |
*/ | |
export function createActionCreator< | |
TType extends string, | |
TArgs extends any[] = [], | |
TPayload = undefined | |
>(type: TType, createPayload?: (...args: TArgs) => TPayload) { | |
return { | |
type, | |
create(...args: TArgs) { | |
return { | |
type, | |
payload: createPayload ? createPayload(...args) : undefined, | |
} as TPayload extends undefined | |
? { | |
type: TType | |
} | |
: { | |
type: TType | |
payload: TPayload | |
} | |
}, | |
} | |
} | |
/** | |
* A helper function to type an arrow function as a thunk without any casting | |
* @param fn The thunk | |
*/ | |
export function thunk<TReturn>(fn: Thunk<TReturn>) { | |
return fn | |
} | |
/** | |
* Binds dispatch to action creators such that they are automatically dispatched when called | |
* @param actionCreators Map of action creators | |
* @param dispatch The store dispatch function | |
*/ | |
export function bindActionCreators<TActionCreators extends Record<string, (...args: any[]) => any>>( | |
actionCreators: TActionCreators, | |
dispatch: Dispatch | |
): { | |
[K in keyof TActionCreators]: TActionCreators[K] extends (...args: infer TArgs) => infer TAction | |
? (...args: TArgs) => DispatchedAction<TAction> | |
: never | |
} { | |
return Object.keys(actionCreators).reduce( | |
(acc, key) => ({ | |
...acc, | |
[key]: (...args: any[]) => dispatch(actionCreators[key](...args)), | |
}), | |
{} as any | |
) | |
} | |
/** | |
* Same thing as redux-thunk-middleware | |
*/ | |
export const thunkMiddleware: Middleware = store => next => action => | |
next(typeof action === 'function' ? action(store.dispatch, store.getState) : action) | |
/** | |
* A variation of the promise middleware that uses a status field as a discriminant | |
* instead of appending a suffix to the action type | |
*/ | |
export const promiseMiddleware: Middleware = store => next => action => { | |
if (!action.status && action.payload && action.payload.then) { | |
const promise: Promise<any> = action.payload | |
promise | |
.then(payload => | |
store.dispatch({ | |
...action, | |
payload, | |
promise, | |
status: 'fulfilled', | |
}) | |
) | |
.catch(payload => | |
store.dispatch({ | |
...action, | |
payload, | |
promise, | |
status: 'rejected', | |
}) | |
) | |
return next({ | |
...action, | |
promise, | |
status: 'pending', | |
}) | |
} | |
return next(action) | |
} | |
/** | |
* Types of actions created from an action creator, mainly if it's a promise-based | |
* action it can potentially dispatch three actions based on how the promise resolves | |
*/ | |
type ExpandedAction<TActionCreator extends ActionCreator> = ReturnType< | |
TActionCreator['create'] | |
> extends { | |
payload: Promise<any> | |
} | |
? | |
| PendingPromiseAction<ReturnType<TActionCreator['create']>> | |
| FulfilledPromiseAction<ReturnType<TActionCreator['create']>> | |
| RejectedPromiseAction<ReturnType<TActionCreator['create']>> | |
: ReturnType<TActionCreator['create']> | |
/** | |
* Creates a strongly-typed reducer based on an action creator, | |
* Meant to be used inside concatReducers to allow full inference of all types | |
* @param actionCreator The action creator object from createActionCreator | |
* @param reducerImpl The implementation of the reducer | |
*/ | |
export function createReducer<TState, TActionCreator extends ActionCreator>( | |
actionCreator: TActionCreator, | |
reducerImpl: (state: TState, action: ExpandedAction<TActionCreator>) => TState | |
) { | |
const reducer = (state: TState, action: ExpandedAction<TActionCreator>) => | |
reducerImpl(state, action) | |
reducer.type = actionCreator.type | |
return reducer | |
} | |
/** | |
* Concatenates and combines reducers for a slice of state | |
* Meant to be used with reducers created by createReducer | |
* to give full inference to each createReducer call | |
* @param initialState The initial state slice | |
* @param reducers All of the reducers to be used for this slice of state | |
*/ | |
export function concatReducers<TState>( | |
initialState: TState, | |
...reducers: ((state: TState, action: any) => TState)[] | |
) { | |
const reducerMap = reducers.reduce( | |
(acc, reducer) => ({ | |
...acc, | |
[reducer['type']]: reducer, | |
}), | |
{} as Record<string, ReturnType<typeof createReducer>> | |
) | |
return (state: TState = initialState, action: Action) => { | |
const reducer = reducerMap[action.type] | |
if (!reducer) throw new Error(`No reducer found for ${action.type}`) | |
return reducer(state, action) as TState | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// tests -- nothing below here triggers `noImplictAny`, and all parameters are fully inferred | |
import { | |
createActionCreator, | |
dispatch, | |
thunk, | |
bindActionCreators, | |
concatReducers, | |
createReducer, | |
} from './utils' | |
const EMPTY_ACTION = createActionCreator('EMPTY_ACTION') | |
const SYNC_PAYLOAD_ACTION = createActionCreator('PAYLOAD_ACTION', (name: string, age: number) => ({ | |
name, | |
age, | |
})) | |
const ASYNC_PAYLOAD_ACTION = createActionCreator('ASYNC_PAYLOAD_ACTION', () => | |
Promise.resolve([1, 2, 3]) | |
) | |
const thunked = (name: string, number: number) => | |
thunk(dispatch => { | |
dispatch(EMPTY_ACTION.create()) | |
return dispatch(SYNC_PAYLOAD_ACTION.create(name, number)) | |
}) | |
const dispatchedThunked = dispatch(thunked('ferdy', 15)) | |
const thunkedPromise = (name: string, number: number) => | |
thunk(dispatch => { | |
dispatch(SYNC_PAYLOAD_ACTION.create(name, number)) | |
return dispatch(ASYNC_PAYLOAD_ACTION.create()) | |
}) | |
const dispatchedThunkedPromise = dispatch(thunkedPromise('ferdy', 15)) | |
const actions = bindActionCreators( | |
{ | |
thunked, | |
thunkedPromise, | |
asyncPayloadAction: ASYNC_PAYLOAD_ACTION.create, | |
syncPayloadAction: SYNC_PAYLOAD_ACTION.create, | |
emptyAction: EMPTY_ACTION.create, | |
}, | |
dispatch | |
) | |
const initialState = { | |
foo: 'abc', | |
bar: false, | |
baz: 0, | |
bat: null as string | null, | |
} | |
concatReducers( | |
initialState, | |
createReducer(EMPTY_ACTION, (state, action) => ({ | |
...state, | |
foo: action.type, | |
})), | |
createReducer(SYNC_PAYLOAD_ACTION, (state, action) => ({ | |
...state, | |
bar: action.payload.age + '' !== action.payload.name, | |
})), | |
createReducer(ASYNC_PAYLOAD_ACTION, (state, action) => { | |
return action.status === 'fulfilled' | |
? { | |
...state, | |
baz: action.payload[0], | |
} | |
: state | |
}) | |
) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment