Types and Tailcalls

Announcing typeful-redux

published on February 26th, 2018

I am proud to announce the publication of typeful-redux, a fully type-safe, low boilerplate redux wrapper for TypeScript. To my knowledge, this is the first redux wrapper which achieves end-to-end type safety. In particular, the dispatch function / object is fully typed, and these types are maintained when using react-redux's connect function to connect a component to a redux store.

Elevator pitch

This is how you create a reducer and a store with typeful-redux. Note that all calls are fully type-safe and will trigger type errors when used incorrectly.

interface TodoItem {
    task: string;
    completed: boolean;
}

// Create a new reducer with initial state [], then add three actions
const TodoReducer = createReducer([] as TodoItem[])
    ('clear', s => [])
    ('add', (s, newItem: TodoItem) => [...s, newItem])
    ('toggle', (s: TodoItem[], index: number) => [
        ...s.slice(0, i),
        { ...s[i], completed: !s[i].completed },
        ...s.slice(i + 1)
    ]);

// Create the store
const store = new StoreBuilder()
    .addReducer('todos', TodoReducer)
    .addMiddleware(reduxLogger) // as example
    .build();

Both the getState function and all functions on the dispatch object are now fully typechecked - using them incorrectly will trigger a type error:

// The result has type: { todos: TodoItem[] }
const state = store.getState();

// All dispatches are fully type checked

// Dispatches { type: 'todos/clear' }
store.dispatch.todos.clear();

// Dispatches
// { type: 'todos/add',
//   payload: { completed: false,
//              task: 'Provide a fully type-safe interface to redux' }
// }
store.dispatch.todos.add({
    task: 'Provide a fully type-safe interface to redux',
    completed: false
});

// Dispatches { type: 'todos/toggle', payload: 0 }
store.dispatch.todos.toggle(0);

In addition, typeful-redux also provides a typesafe wrapper for react-redux's connect method. This means that any type mismatch when connecting a component to a store will be detected and produce a type error.

A very simple, runnable example app can be found here. A TodoMVC implementation with slightly more features is availabe here.

Motivation

redux is a fantastic approach to manage state in single page applications. Unfortunately, vanilla redux requires some boilerplate and is hard to use in a type-safe way.

typeful-redux's primary goal is to provide a fully type-safe interface to redux. 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. All type-incorrect usages of getState or dispatch should trigger a type errror.

More specifically, typeful-redux seeks to address the following challenges when using redux:

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. The resulting runtime objects are plain redux reducers and stores equipped with the right type definitions (and sometimes some tiny convenience wrappers). 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.

Overview over the library

typeful-redux exports two functions and one class (and a few supporting type definitions). The purpose of the functions is described here. Also see the examples for example usages. If you find the documentation insufficient please file an issue or complain to me via email.

createReducer

This function allows creating a reducer by adding action names and the code 'reducing' the action simultaneously. While adding actions, the type of the reducer is refined so that the right type of the dispatch object can be inferred.

Actions and their handlers can be added by either calling the function with the actions type name and a function handeling the reduction or by using the addSetter (for creating an action without payload) or addHandler methods (for creating an action with payload).

The initial example uses the call syntax to create three actions:

const TodoReducer = createReducer([] as TodoItem[])
    ('clear', s => [])
    ('add', (s: TodoItem[], newItem: TodoItem) => [...s, newItem])
    ('toggle', (s: TodoItem[], index: number) => [
        ...s.slice(0, i),
        { ...s[i], completed: !s[i].completed },
        ...s.slice(i + 1)
    ]);

There is an alternative syntax to create a reducer with addSetter and addHandler methods to add new actions and reduction cases, this looks as follows:

const TodoReducer = createReducer([] as TodoItem[])
    .addSetter('clear', s => [])
    .addHandler('add', (s, newItem: TodoItem) => [...s, newItem])
    .addHandler('toggle', (s, index: number) => [
        ...s.slice(0, i),
        { ...s[i], completed: !s[i].completed },
        ...s.slice(i + 1)
    ]);

StoreBuilder

The StoreBuilder class is used to assemble a store using one or multiple reducers and redux middlewares. It extracts the reducers from the objects created by createReducer and returns a redux store, where the dispatch function is extended by fully typed functions which dispatch the actions created via createReducer. Otherwise the result of the .build() method is a plain redux store where getState() has the right return type inferred.

// Create the store
const store = new StoreBuilder()
    .addReducer('todos', TodoReducer)
    .addMiddleware(reduxLogger) // as example
    .build();

store is a plain redux store with getState, subscribe and dispatch methods. The only difference is that dispatch now also holds objects with methods to enable a type-safe dispatch and that getState has the right return type.

// This is fully typed
store.dispatch.todos.clear();
store.dispatch.todos.add({ task: 'Tell world about typeful-redux', completed: false });
store.dispatch.todos.toggle(0);

The type of store.dispatch.todos is

{
    clear(): void;
    add(newItem: TodoItem): void;
    toggle(index: number): void;
}

Each method dispatches the right action on the store with the passed argument as the payload. Actions are namespaced - so store.dispatch.toggle(0) dispatches a { type: 'todos/toggle', payload: 0 } action. This means that action types no longer have to be globally unique - they just have to be unique for their reducer. This enables using the same reducer for multiple parts of the store.

connect

This is a re-export of the redux connect function, with a more restricted type to ensure that the typing of the dispatch object is known in the mapDispatchToProps function. This makes it possible to propagate type errors through connect, which is not possible with the current type definition of react-redux's connect.

To explain how connect can be used to its full benefit, we must understand the type of the produced store. In general store will have the following type:

type Store<STATE, DISPATCH> = {
    getState(): STATE;
    subscribe(): void;
    dispatch: DISPATCH;
};

where STATE is a map from the reducer names (here: todos) and the state types and DISPATCH is a map from the reducer names (again todos) to functions which dispatch the respective actions.

Now the connect function is set up so given a mapStateToProps which accepts a STATE and a mapDispatchToProps which accepts a DISPATCH, it produces a container which needs to have a { store: Store<STATE, DISPATCH>; } as part of properties. This way the types from the store can be propagated all the way to the components and changing the type of an action-reducer triggers a type-error in all the right places.

interface State {
    todos: TodoItem;
}

interface Dispatch {
    todos: {
        add(description: string): void;
        clear(): void;
        toggle(index: number): void;
    };
}
// Let's say we have a `TodoListComponent` which wants the following
// properties
interface TodoListProps = {
    todos: TodoItem;
    add(description: string): void;
    clear(): void;
    toggle(index: number): void;
}

class TodoListComponent extends React.Component<TodoListProps> {
    // ...
}

const mapStateToProps = (state: State) => state;
const mapDispatchToProps = (dispatch: Dispatch) => dispatch.todos;


// TodoListContainer is infered to have a type which requires a property
// `{ store: Store<State, Dispatch> }`
//
const TodoListContainer = connect(mapStateToProps, mapDispatchToProps)(TodoListComponent);

connect can also be used with mapStateToProps and mapDispatchToProps with a second argument, these second arguments become part of the required properties of the connected container.

How does it all work?

In the next post, we'll explore in more depth how typeful-redux is implemented as it uses some tricks to pick up the types from the reducer and transforms them to give the dispatch objec the right type.


comments powered by Disqus