Skip to content

Instantly share code, notes, and snippets.

@ferdaber
Last active January 25, 2019 00:16
Show Gist options
  • Save ferdaber/2241b88fb602a39e680021a48f311064 to your computer and use it in GitHub Desktop.
Save ferdaber/2241b88fb602a39e680021a48f311064 to your computer and use it in GitHub Desktop.
Strongly typed Redux
// 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
}
}
// 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