├── examples ├── todomvc │ ├── .gitignore │ ├── src │ │ ├── index.html │ │ ├── reducers │ │ │ ├── Filter.ts │ │ │ └── Todo.ts │ │ ├── index.tsx │ │ └── components │ │ │ ├── Todo.tsx │ │ │ └── TodoList.tsx │ ├── README.md │ └── webpack.config.js └── simple-todo │ ├── src │ ├── index.html │ └── todos.tsx │ ├── README.md │ └── webpack.config.js ├── .gitignore ├── src ├── constants.ts ├── index.ts ├── types │ ├── converters.ts │ ├── helpers.ts │ └── redux.ts ├── connect │ └── index.ts ├── handler-map │ ├── index.ts │ └── index.test.ts ├── action-creators │ └── index.ts ├── store │ ├── index.test.ts │ └── index.ts └── reducer │ ├── index.ts │ └── index.test.ts ├── .prettierrc ├── .gitlab-ci.yml ├── tsconfig.json ├── LICENSE ├── package.json ├── test └── types │ ├── ts-tests.ts │ └── type-helpers.ts └── README.md /examples/todomvc/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | yarn.lock 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | package-lock.json 3 | yarn.lock 4 | dist 5 | coverage 6 | .vscode -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | export const INITIAL_STATE_KEY = '___TYPEFUL_REDUX_INTERNAL___INITIAL_STATE___'; -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "typescript", 3 | "printWidth": 80, 4 | "tabWidth": 4, 5 | "singleQuote": true 6 | } -------------------------------------------------------------------------------- /.gitlab-ci.yml: -------------------------------------------------------------------------------- 1 | image: node:4 2 | 3 | before_script: 4 | - npm install 5 | 6 | test_job: 7 | stage: test 8 | script: 9 | - npm test -------------------------------------------------------------------------------- /examples/simple-todo/src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 | 5 | 6 | 7 |>,
44 | () => void,
45 | (payload: P) => void
46 | >;
47 |
48 | export type ActionCreatorFromPayload<
49 | ActionName,
50 | Payload extends string | void | object
51 | > = If<
52 | Or{items}
77 | = (
9 | state: S | undefined,
10 | action: A
11 | ) => S;
12 |
13 | export const createReducer = = { [K in keyof S]: Reducer };
51 |
52 | /**
53 | * Turns an object whose values are different reducer functions, into a single
54 | * reducer function. It will call every child reducer, and gather their results
55 | * into a single state object, whose keys correspond to the keys of the passed
56 | * reducer functions.
57 | *
58 | * @template S Combined state object type.
59 | *
60 | * @param reducers An object whose values correspond to different reducer
61 | * functions that need to be combined into one. One handy way to obtain it
62 | * is to use ES6 `import * as reducers` syntax. The reducers may never
63 | * return undefined for any action. Instead, they should return their
64 | * initial state if the state passed to them was undefined, and the current
65 | * state for any unrecognized action.
66 | *
67 | * @returns A reducer function that invokes every reducer inside the passed
68 | * object, and builds a state object with the same shape.
69 | */
70 | interface CombineReducers {
71 | todos
71 |
79 | {items}
82 | ): void;
30 | }
31 |
32 | export type DeepPartial, preloadedState?: DeepPartial) => Store;
50 |
51 | export type ConcreteStoreCreator = <
52 | S,
53 | A extends Action,
54 | D extends Dispatch,
57 | preloadedState?: DeepPartial
58 | ) => Store;
59 | // = Dispatch
60 |
61 | export const createStore = Redux.createStore as ConcreteStoreCreator;
62 |
63 | export type StoreEnhancer<
64 | S,
65 | A extends Action,
66 | D extends Dispatch,
67 | D2 extends Dispatch
68 | > = (storeCreator: StoreCreator) => StoreCreator;
69 |
70 | export interface MiddlewareAPI, D2 extends Dispatch>(
87 | middleware1: Middleware
88 | ): StoreEnhancer;
89 | // {
133 | ,
139 | getState: () => S,
140 | extraArgument: E
141 | ) => R;
142 |
143 | export interface ThunkMiddleware
144 | extends Middleware, ThunkDispatch> {
145 | withExtraArgument(
146 | extraArgument: E
147 | ): ThunkMiddleware;
148 | }
149 |
150 | // export type ConcreteThunkMiddleware =
151 |
152 | // (api: MiddlewareAPI(extraArgument: E): ThunkMiddleware;
155 | // Middleware
156 | // withExtraArgument(extraArgument: E): ThunkMiddleware;
157 | // }
158 |
159 | export const thunk = (Thunk.default as any) as {
160 | , D2 extends Dispatch>(
161 | api: MiddlewareAPI(
164 | extraArgument: E
165 | ) => ThunkMiddleware;
166 | };
167 |
--------------------------------------------------------------------------------
/src/reducer/index.test.ts:
--------------------------------------------------------------------------------
1 | import { resolveTypes } from 'resolve-types';
2 | import { createHandlerMap, createReducer } from '..';
3 |
4 | describe('createReducer', () => {
5 | describe('returns a function that', () => {
6 | it('when passed undefined returns the initial state', () => {
7 | const initialState = { a_key: 'a value' };
8 | const handlerMap = createHandlerMap(initialState, {});
9 | const reducer = createReducer(handlerMap);
10 | const actual = reducer(undefined, {} as never);
11 | expect(actual).toEqual(initialState);
12 | });
13 |
14 | it('when passed an existing action invokes the associated handler', () => {
15 | const initialState = { a_key: 'a value' };
16 | const handlerMap = createHandlerMap(initialState, {
17 | my_action: (s: { a_key: string }, p: string) => {
18 | expect(s.a_key).toEqual('value passed to reducer');
19 | return { a_key: p };
20 | }
21 | });
22 | const reducer = createReducer(handlerMap);
23 | const { a_key: actual } = reducer(
24 | { a_key: 'value passed to reducer' },
25 | { type: 'my_action', payload: 'expected return value' }
26 | );
27 | expect(actual).toEqual('expected return value');
28 | });
29 |
30 | it('when passed a non-existing action returns the passed-in state', () => {
31 | const initialState = { a_key: 'a value' };
32 | const handlerMap = {
33 | my_action: (s: { a_key: string }, p: string) => {
34 | expect(s.a_key).toEqual('value passed to reducer');
35 | return { a_key: p };
36 | }
37 | };
38 | const reducer = createReducer(
39 | createHandlerMap(initialState, handlerMap)
40 | );
41 | const { a_key: actual } = reducer(
42 | { a_key: 'value passed to reducer' },
43 | {
44 | type: 'unknown_action' as any,
45 | payload: 'not-expected return value'
46 | }
47 | );
48 | expect(actual).toEqual('value passed to reducer');
49 | });
50 |
51 | it('returns a full state even if a handler only provides a partial update', () => {
52 | const initialState = { a: 3, b: 'initial b' };
53 | const handlerMap = {
54 | foo: { b: 'b set by foo' }
55 | };
56 | const reducer = createReducer(
57 | createHandlerMap(initialState, handlerMap)
58 | );
59 | const newState = reducer(
60 | { a: 5, b: 'different b' },
61 | { type: 'foo' }
62 | );
63 | expect(newState).toEqual({ a: 5, b: 'b set by foo' });
64 | });
65 |
66 | it('correctly handles updates for handlers which are objects', () => {
67 | const initialState = { a: 3, b: 'initial b' };
68 | const handlerMap = {
69 | foo: { a: -3, b: 'b set by foo' }
70 | };
71 | const reducer = createReducer(
72 | createHandlerMap(initialState, handlerMap)
73 | );
74 | const newState = reducer(
75 | { a: 5, b: 'different b' },
76 | { type: 'foo' }
77 | );
78 | expect(newState).toEqual({ a: -3, b: 'b set by foo' });
79 | });
80 |
81 | it('correctly handles updates for handlers which are nullary functions', () => {
82 | const initialState = { a: 3, b: 'initial b' };
83 | const handlers = {
84 | foo: () => ({ a: -3, b: 'b set by foo' }),
85 | bar: () => ({ b: 'b set by bar' })
86 | };
87 | const handlerMap = createHandlerMap(initialState, handlers);
88 | const reducer = createReducer(handlerMap);
89 |
90 | const stateAfterFoo = reducer(
91 | { a: 5, b: 'different b' },
92 | { type: 'foo' }
93 | );
94 | expect(stateAfterFoo).toEqual({ a: -3, b: 'b set by foo' });
95 |
96 | const stateAfterBar = reducer(
97 | { a: 5, b: 'different b' },
98 | { type: 'bar' }
99 | );
100 | expect(stateAfterBar).toEqual({ a: 5, b: 'b set by bar' });
101 | });
102 |
103 | it('correctly handles updates for handlers which are unary functions', () => {
104 | const initialState = { a: 3, b: 'initial b' };
105 | const handlerMap = createHandlerMap(initialState, {
106 | foo: s => ({ a: s.a + 1, b: 'b set by foo' }),
107 | bar: s => ({ b: s.b + ' and bar!' })
108 | });
109 | const reducer = createReducer(handlerMap);
110 |
111 | const stateAfterFoo = reducer(
112 | { a: 5, b: 'different b' },
113 | { type: 'foo' }
114 | );
115 | expect(stateAfterFoo).toEqual({ a: 6, b: 'b set by foo' });
116 |
117 | const stateAfterBar = reducer(
118 | { a: 5, b: 'different b' },
119 | { type: 'bar' }
120 | );
121 | expect(stateAfterBar).toEqual({ a: 5, b: 'different b and bar!' });
122 | });
123 |
124 | it('correctly handles updates for handlers which are binary functions', () => {
125 | const initialState = { a: 3, b: 'initial b' };
126 | const handlerMap = {
127 | foo: (s, x: number) => ({ a: s.a > 3 ? x : 0 - x, b: 'b set by foo' }),
128 | bar: (s, x: string) => ({ b: s.a > 3 ? '> 3' + x : '<= 3' + x })
129 | };
130 | const reducer = createReducer(createHandlerMap(initialState, handlerMap));
131 |
132 | const stateAfterFoo = reducer(
133 | { a: 5, b: 'different b' },
134 | { type: 'foo', payload: 10 }
135 | );
136 | expect(stateAfterFoo).toEqual({ a: 10, b: 'b set by foo' });
137 |
138 | const stateAfterBar = reducer(
139 | { a: 2, b: 'different b' },
140 | { type: 'bar', payload: ' hello!!!' }
141 | );
142 | expect(stateAfterBar).toEqual({ a: 2, b: '<= 3 hello!!!' });
143 | });
144 | });
145 |
146 | describe('infers the right types', () => {
147 | const {
148 | types: { __reducerType }
149 | } = resolveTypes`
150 | import { createReducer, createHandlerMap } from './src';
151 | const state = {};
152 | const handlerMap = createHandlerMap(state, {});
153 | const reducer = createReducer(handlerMap);
154 | type __reducerType = typeof reducer;
155 | `;
156 |
157 | it('has the right type', () => {
158 | expect(__reducerType).toEqual('(state: {}, action: never) => {}');
159 | });
160 | });
161 | });
162 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # typeful-redux
2 |
3 | [](https://www.npmjs.com/package/typeful-redux)
4 |
5 | A type-safe, low boilerplate wrapper for redux to be used in TypeScript projects.
6 |
7 | ## Elevator pitch
8 |
9 | This is how you create a reducer and a store with typeful-redux. Note that all
10 | calls are fully type-safe and will trigger type errors when used incorrectly.
11 |
12 | ```TypeScript
13 | interface Todo {
14 | task: string;
15 | completed: boolean;
16 | }
17 |
18 | // This map of handlers contains all the information we need
19 | // to create fully type-safe reducers and (bound or unbound)
20 | // action creators
21 | const todoHandler = {
22 | CLEAR: (_state: Todo[]) => [] as Todo[],
23 | ADD: (state: Todo[], newTodo: Todo) => [...state, newItem],
24 | TOGGLE: (state: Todo[], index: number) => [
25 | ...state.slice(0, index),
26 | { task: state[index].task, completed: !state[index].completed },
27 | ...state.slice(index + 1)
28 | ]
29 | };
30 |
31 | const initialState: Todo[] = [];
32 |
33 | // todoReducer has the type information on what state and
34 | // actions it can reduce
35 | const todoReducer = createReducer(initialState, todoHandler);
36 |
37 | // Creates fully typed action creators for CLEAR, ADD, TOGGLE
38 | const actionCreators = createActionCreators(todoHandler);
39 |
40 | // Create the store - combineReducers is not needed but works ;)
41 | const store = createStore(
42 | combineReducers({ todos: todoReducer })
43 | );
44 |
45 | // The state has type: { todos: Todo[] }
46 | const state = store.getState();
47 |
48 | // type error: action has the wrong form - expected just { type: 'CLEAR' }
49 | store.dispatch({ type: 'CLEAR', payload: 'unexpected payload' });
50 | // type error: missing payload
51 | store.dispatch({ type: 'ADD' });
52 |
53 | // These all typecheck
54 | store.dispatch(actionCreators.ADD({ task: 'new todo', completed: false }));
55 | store.dispatch(actionCreators.TOGGLE(0));
56 | store.dispatch(actionCreators.CLEAR());
57 |
58 | // Bound action creators dispatch directly - convenient for mapDispatchToProps
59 | // This is equivalent to the above
60 | const boundCreators = bindActionCreators(actionCreators, store.dispatch);
61 | boundCreators.ADD({ task: 'new todo', completed: false });
62 | boundCreators.TOGGLE(0);
63 | boundCreators.CLEAR();
64 | ```
65 |
66 | A very simple, runnable example app can be found [here](./examples/simple-todo/). A TodoMVC implementation with
67 | slightly more features is availabe [here](./examples/todomvc/).
68 |
69 | ## Motivation
70 |
71 | [redux] is a fantastic approach to manage state in single page applications.
72 | Unfortunately, vanilla redux requires some boilerplate and is hard to use
73 | in a type-safe way.
74 |
75 | [typeful-redux]'s goal is to make it easy to use redux in a fully type-safe way while also reducing the amount of boilerplate required. This means the redux `getState` and `dispatch` functions need to have the right types and these types should be maintained when using the [react-redux] `connect` function. Furthermore, typeful-redux also provides helper functions
76 | to easily create fully type-safe bound and un-bound action creators.
77 |
78 | More specifically, [typeful-redux] seeks to address the following challenges when using redux:
79 |
80 | - **Full type safety:** redux makes it hard to fully type the `dispatch`
81 | method, to guarantee that only actions are dispatched which are handled by the store or that the dispatched actions are type correct (i.e. have the right payload).
82 |
83 | typeful-redux creates a store that gives a fully type-safe dispatch object, where every action is available as a function expecting the right payload. The `getState` method is also fully typed and returns a state with the right type.
84 |
85 | - **Low Boilerplate:** redux needs actions, possibly action creators and reducers.
86 | When trying to set this up in a type-safe way, many things need to be written down twice (or more). This introduces an opportunity for inconsistencies and errors.
87 |
88 | In typeful-redux, actions and their reducers are defined simultaneously,reducing the amount of code that needs to be written and maintained.
89 |
90 | - **Avoid inconsistencies:** When actions and reducers are defined
91 | seperately, there is the potential to forget handeling an action (or to misspell a type in a reducer's switch statement). typeful-redux makes this impossible by requiring the simultaneous definition of an action with its reducing code.
92 |
93 | Besides these differences and different surface appearence, typeful-redux **is not an alternative redux implementation**, it is just a thin wrapper around reducer and store creation.
94 | In fact the createStore and combineReducer functions are exactly the functions from redux,
95 | they are just typed differently. All the existing redux ecosystem should be usable with this library. Please file an issue if you have trouble using a redux library with typeful-redux.
96 |
97 | ## Documentation
98 |
99 | typeful-redux exports a few functions and type operators to make type-safe store and
100 | action creator creations a breeze. All functions and operators are described here. Also see the [examples](./examples/) for example usages. If you find the documentation insufficient please file an issue or complain to me via email (see profile).
101 |
102 | ### typeful-redux functions and concepts
103 |
104 | #### HandlerMap
105 |
106 | A key concept in typeful-redux is the `HandlerMap`, an object
107 | from action names to handler functions which is used to create
108 | the reducer and action creators. The idea is that this object
109 | contains all the naming and type information and thus it is
110 | not necessary to type any more than that (pun intended!).
111 |
112 | #### `Reducer`
113 |
114 | A simple type capturing the type of reducers:
115 |
116 | ```TypeScript
117 | type Reducer = (
59 | state: S | undefined,
60 | action: A
61 | ) => S;
62 |
63 | /**
64 | * Object whose values correspond to different reducer functions.
65 | *
66 | * @template A The type of actions the reducers can potentially respond to.
67 | */
68 | export type ReducersMapObject = {
69 | [K in keyof S]: Reducer
70 | };
71 |
72 | /**
73 | * Turns an object whose values are different reducer functions, into a single
74 | * reducer function. It will call every child reducer, and gather their results
75 | * into a single state object, whose keys correspond to the keys of the passed
76 | * reducer functions.
77 | *
78 | * @template S Combined state object type.
79 | *
80 | * @param reducers An object whose values correspond to different reducer
81 | * functions that need to be combined into one. One handy way to obtain it
82 | * is to use ES6 `import * as reducers` syntax. The reducers may never
83 | * return undefined for any action. Instead, they should return their
84 | * initial state if the state passed to them was undefined, and the current
85 | * state for any unrecognized action.
86 | *
87 | * @returns A reducer function that invokes every reducer inside the passed
88 | * object, and builds a state object with the same shape.
89 | */
90 | // export function combineReducers(reducers: ReducersMapObject): Reducer;
91 | // export function combineReducers(reducers: ReducersMapObject): Reducer;
92 |
93 | /* store */
94 |
95 | /**
96 | * A *dispatching function* (or simply *dispatch function*) is a function that
97 | * accepts an action or an async action; it then may or may not dispatch one
98 | * or more actions to the store.
99 | *
100 | * We must distinguish between dispatching functions in general and the base
101 | * `dispatch` function provided by the store instance without any middleware.
102 | *
103 | * The base dispatch function *always* synchronously sends an action to the
104 | * store's reducer, along with the previous state returned by the store, to
105 | * calculate a new state. It expects actions to be plain objects ready to be
106 | * consumed by the reducer.
107 | *
108 | * Middleware wraps the base dispatch function. It allows the dispatch
109 | * function to handle async actions in addition to actions. Middleware may
110 | * transform, delay, ignore, or otherwise interpret actions or async actions
111 | * before passing them to the next middleware.
112 | *
113 | * @template A The type of things (actions or otherwise) which may be
114 | * dispatched.
115 | */
116 | export interface Dispatch {
117 | {
136 | /**
137 | * Dispatches an action. It is the only way to trigger a state change.
138 | *
139 | * The `reducer` function, used to create the store, will be called with the
140 | * current state tree and the given `action`. Its return value will be
141 | * considered the **next** state of the tree, and the change listeners will
142 | * be notified.
143 | *
144 | * The base implementation only supports plain object actions. If you want
145 | * to dispatch a Promise, an Observable, a thunk, or something else, you
146 | * need to wrap your store creating function into the corresponding
147 | * middleware. For example, see the documentation for the `redux-thunk`
148 | * package. Even the middleware will eventually dispatch plain object
149 | * actions using this method.
150 | *
151 | * @param action A plain object representing “what changed”. It is a good
152 | * idea to keep actions serializable so you can record and replay user
153 | * sessions, or use the time travelling `redux-devtools`. An action must
154 | * have a `type` property which may not be `undefined`. It is a good idea
155 | * to use string constants for action types.
156 | *
157 | * @returns For convenience, the same action object you dispatched.
158 | *
159 | * Note that, if you use a custom middleware, it may wrap `dispatch()` to
160 | * return something else (for example, a Promise you can await).
161 | */
162 | dispatch: Dispatch;
163 |
164 | /**
165 | * Reads the state tree managed by the store.
166 | *
167 | * @returns The current state tree of your application.
168 | */
169 | getState(): S;
170 |
171 | /**
172 | * Adds a change listener. It will be called any time an action is
173 | * dispatched, and some part of the state tree may potentially have changed.
174 | * You may then call `getState()` to read the current state tree inside the
175 | * callback.
176 | *
177 | * You may call `dispatch()` from a change listener, with the following
178 | * caveats:
179 | *
180 | * 1. The subscriptions are snapshotted just before every `dispatch()` call.
181 | * If you subscribe or unsubscribe while the listeners are being invoked,
182 | * this will not have any effect on the `dispatch()` that is currently in
183 | * progress. However, the next `dispatch()` call, whether nested or not,
184 | * will use a more recent snapshot of the subscription list.
185 | *
186 | * 2. The listener should not expect to see all states changes, as the state
187 | * might have been updated multiple times during a nested `dispatch()` before
188 | * the listener is called. It is, however, guaranteed that all subscribers
189 | * registered before the `dispatch()` started will be called with the latest
190 | * state by the time it exits.
191 | *
192 | * @param listener A callback to be invoked on every dispatch.
193 | * @returns A function to remove this change listener.
194 | */
195 | subscribe(listener: () => void): Unsubscribe;
196 |
197 | /**
198 | * Replaces the reducer currently used by the store to calculate the state.
199 | *
200 | * You might need this if your app implements code splitting and you want to
201 | * load some of the reducers dynamically. You might also need this if you
202 | * implement a hot reloading mechanism for Redux.
203 | *
204 | * @param nextReducer The reducer for the store to use instead.
205 | */
206 | replaceReducer(nextReducer: Reducer): void;
207 | }
208 |
209 | // export type DeepPartial(
224 | reducer: Reducer,
225 | enhancer: StoreEnhancer & Ext;
227 | // (
228 | // reducer: Reducer,
229 | // preloadedState: DeepPartial,
230 | // enhancer?: StoreEnhancer & Ext;
232 | }
233 |
234 | /**
235 | * Creates a Redux store that holds the state tree.
236 | * The only way to change the data in the store is to call `dispatch()` on it.
237 | *
238 | * There should only be a single store in your app. To specify how different
239 | * parts of the state tree respond to actions, you may combine several
240 | * reducers
241 | * into a single reducer function by using `combineReducers`.
242 | *
243 | * @template S State object type.
244 | *
245 | * @param reducer A function that returns the next state tree, given the
246 | * current state tree and the action to handle.
247 | *
248 | * @param [preloadedState] The initial state. You may optionally specify it to
249 | * hydrate the state from the server in universal apps, or to restore a
250 | * previously serialized user session. If you use `combineReducers` to
251 | * produce the root reducer function, this must be an object with the same
252 | * shape as `combineReducers` keys.
253 | *
254 | * @param [enhancer] The store enhancer. You may optionally specify it to
255 | * enhance the store with third-party capabilities such as middleware, time
256 | * travel, persistence, etc. The only store enhancer that ships with Redux
257 | * is `applyMiddleware()`.
258 | *
259 | * @returns A Redux store that lets you read the state, dispatch actions and
260 | * subscribe to changes.
261 | */
262 | // export const createStore: StoreCreator;
263 |
264 | /**
265 | * A store enhancer is a higher-order function that composes a store creator
266 | * to return a new, enhanced store creator. This is similar to middleware in
267 | * that it allows you to alter the store interface in a composable way.
268 | *
269 | * Store enhancers are much the same concept as higher-order components in
270 | * React, which are also occasionally called “component enhancers”.
271 | *
272 | * Because a store is not an instance, but rather a plain-object collection of
273 | * functions, copies can be easily created and modified without mutating the
274 | * original store. There is an example in `compose` documentation
275 | * demonstrating that.
276 | *
277 | * Most likely you'll never write a store enhancer, but you may use the one
278 | * provided by the developer tools. It is what makes time travel possible
279 | * without the app being aware it is happening. Amusingly, the Redux
280 | * middleware implementation is itself a store enhancer.
281 | *
282 | * @template Ext Store extension that is mixed into the Store type.
283 | * @template StateExt State extension that is mixed into the state type.
284 | */
285 | export type StoreEnhancer
291 | ) => // ,
292 | // preloadedState?: DeepPartial
293 | Store & Ext;
294 |
295 | /* middleware */
296 |
297 | export interface MiddlewareAPI