Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feature(types): Add Handler type #4

Merged
merged 1 commit into from
Jun 13, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 10 additions & 3 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ Handle the actions:
```ts
// src/pages/MyPage/reducer.ts

import { handleActions } from '@housinganywhere/safe-redux';
import { handleActions, Handler } from '@housinganywhere/safe-redux';

import { User } from '../types';

Expand All @@ -69,11 +69,17 @@ const initialState: State = {
count: 0,
};

// `Handler` type can be used when you don't want to define the handlers inline
const handleIncBy: Handler<State, typeof INC_BY, Actions> = (
{ count },
{ payload },
) => ({ count: count + payload });

const reducer = handleActions<State, ActionTypes, Actions>(
{
[INC]: ({ count }) => ({ count: count + 1 }),
[DEC]: ({ count }) => ({ count: count - 1 }),
[INC_BY]: ({ count }, { payload }) => ({ count: count + payload }),
[INC_BY]: handleIncBy,
[WITH_META]: ({ count }, { payload }) => ({ count: count + payload }),
},
initialState,
Expand Down Expand Up @@ -140,4 +146,5 @@ export default connect<StateProps, DispatchProps>(
- Added `handleActions` to create type safe reducers.
- Smaller API. `safe-redux` only exports a few functions and types:
- Functions: `createAction` and `handleActions`.
- Types: `Action`, `ActionsUnion`, `ActionsOfType` and `BindAction`.
- Types: `Action`, `ActionsUnion`, `ActionsOfType`, `Handler` and
`BindAction`.
65 changes: 49 additions & 16 deletions src/index.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { createAction, handleActions } from './index';
import { createAction, handleActions, ActionsUnion, Handler } from './index';

describe('handleActions', () => {
const ACTION = 'SOME_ACTION';
Expand All @@ -12,68 +12,68 @@ describe('handleActions', () => {
initialState,
);

it('should handle actions', () => {
const action = { type: ACTION };
it('handles actions', () => {
const action = { type: ACTION, error: false };

const actual = reducer(initialState, action);

expect(actual).toBe(returnedState);
expect(actionHandler).toBeCalledWith(initialState, action);
});

it('should return state when action type is not handled', () => {
const action = { type: 'other_action' };
const state = { foo: 'foo' };
it('returns state when action type is not handled', () => {
const action = { type: 'other_action', error: false };
const state = { foo: 'foo', error: false };

const actual = reducer(state, action);

expect(actual).toBe(state);
});

it('should default to initialState when state is undefined', () => {
const action = { type: ACTION };
it('defaults to initialState when state is undefined', () => {
const action = { type: ACTION, error: false };

reducer(undefined, action);

expect(actionHandler).toBeCalledWith(initialState, action);
});

it('should return initialState when state is undefined and action is not handled', () => {
const actual = reducer(undefined, { type: '@@REDUX/INIT' });
it('returns initialState when state is undefined and action is not handled', () => {
const actual = reducer(undefined, { type: '@@REDUX/INIT', error: false });

expect(actual).toBe(initialState);
});
});

describe('createAction', () => {
it('should create an action with only type and error props', () => {
it('creates an action with only type and error props', () => {
const action = createAction('action-type');

expect(action).toEqual({ type: 'action-type', error: false });
});

it('should create an action false error prop', () => {
it('creates an action false error prop', () => {
const action = createAction('action-type');

expect(action.error).toBe(false);
});

it('should create an action with type, payload and error props', () => {
it('creates an action with type, payload and error props', () => {
const payload = { foo: 'bar' };
const action = createAction('action-type', payload);

expect(action).toEqual({ type: 'action-type', payload, error: false });
});

it('should create an action true error prop', () => {
it('creates an action true error prop', () => {
const payload = new Error('error');
const action = createAction('action-type', payload);

expect(action.error).toBe(true);
expect(action).toEqual({ type: 'action-type', payload, error: true });
});

it('should create an action with type, payload, meta and error props', () => {
it('creates an action with type, payload, meta and error props', () => {
const payload = { foo: 'bar' };
const meta = { foo: 'bar' };
const action = createAction('action-type', payload, meta);
Expand All @@ -86,7 +86,7 @@ describe('createAction', () => {
});
});

it('should create an action true error prop and meta', () => {
it('creates an action true error prop and meta', () => {
const payload = new Error('error');
const meta = { foo: 'bar' };
const action = createAction('action-type', payload, meta);
Expand All @@ -100,3 +100,36 @@ describe('createAction', () => {
});
});
});

// type tests, all these should type check

interface State {
foo: string;
}

const enum ActionTypes {
foo = 'foo',
bar = 'bar',
baz = 'baz',
}

const Actions = {
foo: () => createAction(ActionTypes.foo),
bar: (s: string) => createAction(ActionTypes.bar, s),
baz: (n: number) => createAction(ActionTypes.baz, n),
};
type Actions = ActionsUnion<typeof Actions>;

const handleBaz: Handler<State, ActionTypes.baz, Actions> = (
s,
{ payload },
) => ({ foo: s.foo + payload.toString() });

const reducer = handleActions<State, ActionTypes, Actions>(
{
foo: () => ({ foo: 'foo' }),
bar: (s, { payload }) => ({ foo: s.foo + payload }),
baz: handleBaz,
},
{ foo: 'bar' },
);
30 changes: 17 additions & 13 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
export { BindAction } from './utils';
import { AnyFunction, StringMap } from './utils';
import { ReturnType } from './utils';

// We use conditional types so we can have only one type for defining Action
export type Action<
Expand All @@ -14,16 +14,22 @@ export type Action<
? Readonly<{ type: T; payload: P; error: boolean }>
: Readonly<{ type: T; payload: P; meta: M; error: boolean }>;

export type ActionsUnion<A extends StringMap<AnyFunction>> = ReturnType<
A[keyof A]
>;
type ActionCreator = (...args: any[]) => Action;
type ActionCreators = { [k: string]: ActionCreator };

export type ActionsUnion<A extends ActionCreators> = ReturnType<A[keyof A]>;

// conditional type for filtering actions in epics/effects
export type ActionsOfType<
ActionUnion,
ActionType extends string
> = ActionUnion extends Action<ActionType> ? ActionUnion : never;

export type Handler<State, ActionType extends string, Actions> = (
state: State,
action: ActionsOfType<Actions, ActionType>,
) => State;

export function createAction<T extends string>(type: T): Action<T>;
export function createAction<T extends string, P>(
type: T,
Expand Down Expand Up @@ -51,13 +57,11 @@ export function createAction<T extends string, P, M>(
export function handleActions<
State,
Types extends string,
Actions extends ActionsUnion<{ [T in Types]: AnyFunction }>
>(
handler: {
[T in Types]: (state: State, action: ActionsOfType<Actions, T>) => State;
},
initialState: State,
) {
return (state = initialState, action: Actions): State =>
handler[action.type] ? handler[action.type](state, action) : state;
Actions extends ActionsUnion<{ [T in Types]: ActionCreator }>
>(handlers: { [T in Types]: Handler<State, T, Actions> }, initialState: State) {
return (state = initialState, action: Actions): State => {
const handler = handlers[action.type];

return handler ? handler(state, action) : state;
};
}
11 changes: 6 additions & 5 deletions src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,14 +85,15 @@ type ChangeReturnType<F, R> = F extends () => any
) => R
: never;

export type AnyFunction = (...args: any[]) => any;
// TS `ReturnType` defaults to `any` instead of `never`
export type ReturnType<T extends (...args: any[]) => any> = T extends (
...args: any[]
) => infer R
? R
: never;

// tslint:enable no-any

// use case example:
// @link http://bit.ly/2KSnhEK
export type BindAction<A> = ChangeReturnType<A, void>;

export interface StringMap<T> {
[key: string]: T;
}