Skip to content

Commit

Permalink
feat(reducer): add createReducer and better typed action creators
Browse files Browse the repository at this point in the history
  • Loading branch information
omichelsen committed Aug 23, 2019
1 parent 400f842 commit cbb355b
Show file tree
Hide file tree
Showing 6 changed files with 247 additions and 70 deletions.
58 changes: 26 additions & 32 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ dispatch(foo(5)); // { type: 'FOO', payload: 5 }
When handling the action in a reducer, you simply cast the action function to a string to return the type. This ensures type safety (no spelling errors) and you can use code navigation to find all uses of an action.

```js
const fooType = String(foo); // 'FOO'
const fooType = foo.toString(); // 'FOO'
```

### Asynchronous action
Expand Down Expand Up @@ -87,48 +87,38 @@ dispacth(fetchData.rejected(err)); // { type: 'FETCH_DATA_REJECTED', payloa
But normally you only need them when you are writing reducers:

```js
case String(fetchData.pending): // 'FETCH_DATA_PENDING'
case String(fetchData.fulfilled): // 'FETCH_DATA_FULFILLED'
case String(fetchData.rejected): // 'FETCH_DATA_REJECTED'
case fetchData.pending.toString(): // 'FETCH_DATA_PENDING'
case fetchData.fulfilled.toString(): // 'FETCH_DATA_FULFILLED'
case fetchData.rejected.toString(): // 'FETCH_DATA_REJECTED'
```

Note that if you try and use the base function in a reducer, an error will be thrown to ensure you are not listening for an action that will never happen:

```js
case String(fetchData): // throws an error
case fetchData.toString(): // throws an error
```

### Async reducer
### Reducer

You can now handle the different events in your reducer by referencing the possible outcome states:
To create a type safe reducer `createReducer` takes a list of handlers that accept one or more actions and returns the new state.

```js
import { fetchData } from './actions';
import { createAction, createReducer } from 'redux-promise-middleware-actions';

export default (state, action) => {
switch (action.type) {
case String(fetchData.pending):
return {
...state,
pending: true,
};
case String(fetchData.fulfilled):
return {
...state,
data: action.payload,
error: undefined,
pending: false,
};
case String(fetchData.rejected):
return {
...state,
error: action.payload,
pending: false,
};
default:
return state;
}
};
const sync = createAction('SYNC');
const async = createAsyncAction('ASYNC', () => Promise.resolve('data'));

const defaultState = {};

const reducer = createReducer(defaultState, (handleAction) => [
handleAction(sync, (state) => ({ ...state, data: undefined })),
handleAction(async.pending, (state) => ({ ...state, pending: true })),
handleAction(async.fulfilled, (state, { payload }) => ({ ...state, pending: false, data: payload })),
handleAction(async.rejected, (state, { payload }) => ({ ...state, pending: false, error: payload })),
]);

reducer(undefined, sync()); //=> { data: undefined }
reducer(undefined, async()); //=> { pending: true, data: ..., error: ... }
```

#### Async reducer helper
Expand Down Expand Up @@ -212,3 +202,7 @@ dispatch(foo());
// { type: 'FETCH_DATA/FULFILLED', payload: ... }
// { type: 'FETCH_DATA/REJECTED', payload: ... }
```

## Acknowledgements

Thanks to [Deox](https://github.com/thebrodmann/deox/) for a lot of inspiration for the TypeScript types.
70 changes: 42 additions & 28 deletions src/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,39 +2,53 @@ export const onPending = (type: any, delimiter = '_') => `${type}${delimiter}PEN
export const onFulfilled = (type: any, delimiter = '_') => `${type}${delimiter}FULFILLED`;
export const onRejected = (type: any, delimiter = '_') => `${type}${delimiter}REJECTED`;

export interface IAction<Payload, Metadata = undefined> {
type: string;
payload?: Payload;
error?: boolean;
meta?: Metadata;
}
export type Action<
Type extends string,
Payload = undefined,
Meta = undefined
> = Payload extends undefined
? (Meta extends undefined ? { type: Type } : { type: Type; meta: Meta })
: (Payload extends Error
? (Meta extends undefined
? { type: Type; payload: Payload; error: true }
: { type: Type; payload: Payload; meta: Meta; error: true })
: (Meta extends undefined
? { type: Type; payload: Payload }
: { type: Type; payload: Payload; meta: Meta }));

export type AnyAction = Action<string>;

export function createAction(
type: string
): () => IAction<undefined>;
export type ActionCreator<T extends AnyAction, U extends any[] = any> = {
(...args: U): T
toString(): T['type']
};

export function createAction<Payload, U extends any[]>(
type: string,
export function createAction<Type extends string>(
type: Type
): ActionCreator<Action<Type>>;

export function createAction<Type extends string, Payload, U extends any[]>(
type: Type,
payloadCreator: (...args: U) => Payload
): (...args: U) => IAction<Payload>;
): ActionCreator<Action<Type, Payload>, U>;

export function createAction<Payload, Metadata, U extends any[]>(
type: string,
export function createAction<Type extends string, Payload, Metadata, U extends any[]>(
type: Type,
payloadCreator: (...args: U) => Payload,
metadataCreator?: (...args: U) => Metadata
): (...args: U) => IAction<Payload, Metadata>;
): ActionCreator<Action<Type, Payload, Metadata>, U>;

/**
* Standard action creator factory.
* @param type Action type.
* @example
* const addTodo = createAction('TODO_ADD', (name) => ({ name }));
*/
export function createAction<Payload, Metadata, U extends any[]>(
type: string,
export function createAction<Type extends string, Payload, Metadata, U extends any[]>(
type: Type,
payloadCreator?: (...args: U) => Payload,
metadataCreator?: (...args: U) => Metadata
): (...args: U) => IAction<Payload, Metadata> {
) {
return Object.assign(
(...args: U) => ({
type,
Expand All @@ -45,10 +59,10 @@ export function createAction<Payload, Metadata, U extends any[]>(
);
}

export interface IAsyncActionFunction<Payload> extends Function {
pending: () => IAction<undefined>;
fulfilled: (payload: Payload) => IAction<Payload>;
rejected: (payload?: any) => IAction<any>;
export interface AsyncActionFunction<Type extends string, Payload> extends ActionCreator<Action<Type>> {
pending: ActionCreator<Action<Type>>;
fulfilled: ActionCreator<Action<Type, Payload>>;
rejected: ActionCreator<Action<Type, Error>>;
}

/**
Expand All @@ -57,11 +71,11 @@ export interface IAsyncActionFunction<Payload> extends Function {
* @example
* const getTodos = createAsyncAction('TODOS_GET', () => fetch('https://todos.com/todos'));
*/
export function createAsyncAction<Payload, Metadata, U extends any[]>(
type: string,
export function createAsyncAction<Type extends string, Payload, Metadata, U extends any[]>(
type: Type,
payloadCreator: (...args: U) => Promise<Payload>,
metadataCreator?: (...args: U) => Metadata,
options: {
{ promiseTypeDelimiter: delimiter }: {
promiseTypeDelimiter?: string
} = {}
) {
Expand All @@ -73,9 +87,9 @@ export function createAsyncAction<Payload, Metadata, U extends any[]>(
},
},
{
pending: createAction(onPending(type, options.promiseTypeDelimiter)),
fulfilled: createAction(onFulfilled(type, options.promiseTypeDelimiter), (payload: Payload) => payload),
rejected: createAction(onRejected(type, options.promiseTypeDelimiter), (payload: any) => payload),
pending: createAction(onPending(type, delimiter)),
fulfilled: createAction(onFulfilled(type, delimiter), (payload: Payload) => payload),
rejected: createAction(onRejected(type, delimiter), (payload: any) => payload),
}
);
}
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
export { createAction, createAsyncAction } from './actions';
export { asyncReducer } from './reducers';
export { asyncReducer, createReducer } from './reducers';
99 changes: 94 additions & 5 deletions src/reducers.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,102 @@
import { IAction, IAsyncActionFunction } from './actions';
import { Action, ActionCreator, AnyAction, AsyncActionFunction } from './actions';

export function getType<
TActionCreator extends { toString(): string },
Type extends string = TActionCreator extends { toString(): infer U } ? U : never
>(actionCreator: TActionCreator) {
return actionCreator.toString() as Type;
}

export type Handler<
TPrevState,
TAction,
TNextState extends TPrevState = TPrevState
> = (prevState: TPrevState, action: TAction) => TNextState;

export type HandlerMap<
TPrevState,
TAction extends AnyAction,
TNextState extends TPrevState = TPrevState
> = { [type in TAction['type']]: Handler<TPrevState, TAction, TNextState> };

export type InferActionFromHandlerMap<
THandlerMap extends HandlerMap<any, any>
> = THandlerMap extends HandlerMap<any, infer T> ? T : never;

export type InferNextStateFromHandlerMap<
THandlerMap extends HandlerMap<any, any>
> = THandlerMap extends HandlerMap<any, any, infer T> ? T : never;

export type CreateHandlerMap<TPrevState> = <
TActionCreator extends ActionCreator<AnyAction>,
TNextState extends TPrevState,
TAction extends AnyAction = TActionCreator extends (...args: any[]) => infer T
? T
: never
>(
actionCreators: TActionCreator | TActionCreator[],
handler: Handler<TPrevState, TAction, TNextState>
) => HandlerMap<TPrevState, TAction, TNextState>;

export function createHandlerMap<
TActionCreator extends ActionCreator<AnyAction>,
TPrevState,
TNextState extends TPrevState,
TAction extends AnyAction = TActionCreator extends (...args: any[]) => infer T
? T
: never
>(
actionCreators: TActionCreator | TActionCreator[],
handler: Handler<TPrevState, TAction, TNextState>
) {
return (Array.isArray(actionCreators) ? actionCreators : [actionCreators])
.map(getType)
.reduce<HandlerMap<TPrevState, TAction, TNextState>>(
(acc, type) => {
acc[type] = handler;
return acc;
},
{} as any
);
}

export function createReducer<
TState,
THandlerMap extends HandlerMap<TState, any, any>
>(
defaultState: TState,
handlerMapsCreator: (handle: CreateHandlerMap<TState>) => THandlerMap[]
) {
const handlerMap = Object.assign({}, ...handlerMapsCreator(createHandlerMap));

return (
state = defaultState,
action: InferActionFromHandlerMap<THandlerMap>
): InferNextStateFromHandlerMap<THandlerMap> => {
const handler = handlerMap[action.type];

return handler ? handler(state as any, action) : state;
};
}

export interface IState<Payload> {
error?: Error;
data?: Payload;
pending?: boolean;
}

export const asyncReducer = <Payload>(fn: IAsyncActionFunction<Payload>) =>
(state: IState<Payload> = {}, action: IAction<any>): IState<Payload> => {
// export const asyncReducer2 = <Type extends string, Payload>(fn: AsyncActionFunction<Type, Payload>) => {
// const defaultState: IState<Payload> = {};

// return createReducer(defaultState, (handleAction) => [
// handleAction(fn.pending, (state) => ({ ...state, pending: true })),
// handleAction(fn.fulfilled, (state, { payload }) => ({ ...state, pending: false, error: undefined, data: payload })),
// handleAction(fn.rejected, (state, { payload }) => ({ ...state, pending: false, error: payload })),
// ]);
// };

export const asyncReducer = <Type extends string, Payload>(fn: AsyncActionFunction<Type, Payload>) =>
(state: IState<Payload> = {}, action: Action<any>): IState<Payload> => {
switch (action.type) {
case String(fn.pending):
return {
Expand All @@ -17,14 +106,14 @@ export const asyncReducer = <Payload>(fn: IAsyncActionFunction<Payload>) =>
case String(fn.fulfilled):
return {
...state,
data: action.payload,
data: (action as any).payload,
error: undefined,
pending: false,
};
case String(fn.rejected):
return {
...state,
error: action.payload,
error: (action as any).payload,
pending: false,
};
default:
Expand Down

0 comments on commit cbb355b

Please sign in to comment.