published on March 4th, 2018
In the last post I announced my library typeful-redux, a fully type-safe, low boilerplate redux wrapper for TypeScript. The library achieves full type safety while allowing very concise application code by pulling some neat tricks with TypeScript's type system. In this post I want to take a closer look at these tricks.
Before looking at how typeful-redux leverages TypeScript's type system, it is instructive to consider what the actual goal of the library is. typeful-redux wants to
give a fully type-safe redux wrapper, meaning that the type of the created store must be as informative as possible. That means in particular that the dispatch function must be fully typed so that all dispatches can be type-checked and we can't dispatch actions that do not have a reducer.
reduce the boilerplate required to configure actions and reducers.
The type of the store looks as follows. STORE_STATE and STORE_DISPATCH are
the generic type parameters that we want to fill out with the right types:
type Store<STORE_STATE, STORE_DISPATCH> {
getState(): STORE_STATE;
dispatch: STORE_DISPATCH;
// I'll leave this out going forward because it is boring
subscribe(() => void): () => void;
}
As an example, if we create a store with a single reducer that we give the name todos and has state TodoItem[] and the single action
add, the full store type will look as follows:
interface TodoItem {
task: string;
completed: boolean;
}
// Create a new reducer with initial state [] and action 'add'
const TodoReducer = createReducer([] as TodoItem[])
('add', (s, payload: string) => [
...s,
{ task: payload, completed: false }
]);
// Create the store
const store = new StoreBuilder()
.addReducer('todos', TodoReducer)
.addMiddleware(reduxLogger) // as example
.build();
// store has the following type:
type StoreType = {
getState(): {
todos: TodoItem[];
};
dispatch: {
todos: {
add(payload: string): void;
}
}
};
So in this case STORE_STATE should be inferred to be { todos: TodoItem[]; }
and STORE_DISPATCH should be inferred to be
todos: {
add(payload: string): void;
}
getState() the Right TypeLet's start with a look at how we can give getState() the right
type. This is easier than getting the dispatch object to look right
but it leverages the same tricks, so it's a good place to start. While
pulling out the types from the library, I'll leave off parts which are not
relevant for the current discussion to make following a bit easier.
The first part is pretty simple: the createReuducer function takes
an initial state and returns a Reducer which has a getInitial()
method that returns this state. So basically the state parameter REDUCER_STATE flows right through createReducer.
type Reducer<REDUCER_STATE, /* ... */> = {
getInitial(): REDUCER_STATE;
// ...
};
const createReducer =
<REDUCER_STATE>(s: REDUCER_STATE): Reducer<REDUCER_STATE> => {
// ...
};
Next comes the StoreBuilder which takes a Reducer and a reducerName
under which the reducer's state and dispatch functions will become
availabe once the store is created via the build method:
class StoreBuilder<STORE_STATE = {}, /* ... */> = {
addReducer<REDUCER_NAME extends string, REDUCER_STATE, /* ... */>(
reducerName: REDUCER_NAME,
reducerBuilder: Reducer<REDUCER_STATE, /* ... */>
): StoreBuilder<STORE_STATE & { [name in REDUCER_NAME]: REDUCER_STATE }, /* ... */>
build(): Store<STORE_STATE, /* ... */>;
}
Here things become a little more interesting: StoreBuilder has a
type parameter STORE_STATE which tracks the state of the store.
We can see this because it is the first
type parameter to Store which build returns.
Now comes the first trick: When another reducer is added, addReducer takes
the reducerName (in the example above, this is 'todos') for which it
infers the type REDUCER_NAME which is a subtype of string. Specifying the type parameter in this way, REDUCER_NAME will be inferred to be the string itself. So if we call addReducer('todos', TodosReducer), then REDUCER_NAME is inferred to be the type 'todos'. This will be useful in a minute.
The second trick is to extract the type of the state from the Reducer. This
is realtively easy, addReducer has a second type parameter REDUCER_STATE which is the first type parameter to Reducer, this way REDUCER_STATE will be inferred to the type of the Reducer's state.
The third trick now brings these things together by returning a StoreBuilder
with a new type. The type parameter STORE_STATE of StoreBuilder now becomes
STORE_STATE & { [name in REDUCER_NAME]: REDUCER_STATE }. As stated above REDUCER_NAME will have been inferred to the
reducerName and REDUCER_STATE to the state type of the reducer. Thus { [name in 'todos']: REDUCER_STATE }
is basically { todos: REDUCER_STATE } and intersecting the store state type so far (STORE_STATE) with { todos: REDUCER_STATE }
basically means give STORE_STATE also the property 'todos' with type REDUCER_STATE.
By combining these methods as described above we can extend the state parameter
with each addReducer call so that we can always give getState() the right
return type.
Hopefully the discussion for getState() was somewhat easy to follow. To give
dispatch the right types we will use the exact same tricks but we need to
go through one more level of indirection.
Let's go back to Reducer and createReducer where we have left of the most
interesting parts. A more complete type of Reducer looks as follows (I still
leave off some parts for clarity, but there are no type tricks involved there,
they are just there for convenience, so we're not missing anything by not
discussing them):
type Reducer<REDUCER_STATE, REDUCER_DISPATCH = {}> = {
// for adding setters, these are actions without a payload
<HANDLER_NAME extends string>(
name: HANDLER_NAME,
handler: (state: REDUCER_STATE) => REDUCER_STATE
): Reducer<REDUCER_STATE, REDUCER_DISPATCH & Dispatch0<HANDLER_NAME>>;
// for adding handlers, these are actions with a payload
<HANDLER_NAME extends string, PAYLOAD>(
name: HANDLER_NAME,
handler: (state: REDUCER_STATE, payload: PAYLOAD) => STATE
): Reducer<REDUCER_STATE, REDUCER_DISPATCH & Dispatch1<HANDLER_NAME, PAYLOAD>>;
// ...
};
type Dispatch0<HANDLER_NAME extends string> = {
[name in HANDLER_NAME]: { (): void; }
};
type Dispatch1<HANDLER_NAME extends string, PAYLOAD> = {
[name in HANDLER_NAME]: { (payload: PAYLOAD): void; }
};
So we see that Reducer has two generic type parameters:
REDUCER_STATE is the type off the state as we have seen previously and REDUCER_DISPATCH is the final type of the dispatch functions that we are interested in (this is what StoreBuilder will use to assemble the type of the dispatch object).
There are two different call signatures, the first one is for adding
actions which don't need a payload (I call them setters here) and the
second one is for actions which do need a payload (called handlers).
They serve essentially the same purpose, but for type inference reasons,
it is necessary to have two different signatures.
Let's focus on the second signature, as both work essentially the same way. Really what we are doing here is we're using the same trick as when building up the full store state type to build up the type of the dispatch functions on this reducer. So let's look again at our example of creating a really simple reducer:
const TodoReducer = createReducer([] as TodoItem[])
('add', (s: TodoItem[], payload: string) => /* ... */)
so name is 'add', REDUCER_STATE is TodoItem[] and PAYLOAD is string. As before, the type parameter HANDLER_NAME will be inferred to be just 'add'. We then return a Reducer<REDUCER_STATE, REDUCER_DISPATCH & Dispatch1<HANDLER_NAME, PAYLOAD>>, substituting
the parameters REDUCER_STATE = TodoItem[], REDUCER_DISPATCH = {}, HANDLER_NAME = 'add' and PAYLOAD = string we get
Reducer<REDUCER_STATE, REDUCER_DISPATCH & Dispatch1<HANDLER_NAME, PAYLOAD>>
= Reducer<TodoItem[], {} & Dispatch1<'add', string>>
= Reducer<TodoItem[], {} & { [name in 'add']: { (payload: string): void } }>
= Reducer<TodoItem[], { [name in 'add']: { (payload: string): void } }>
= Reducer<TodoItem[], { add: { (payload: string): void } }>
= Reducer<TodoItem[], { add(payload: string): void; }>
So now the DISPATCH type parameter is { add(payload: string): void; }, thus
a single function with name add that accepts a string and returns void.
It might be instructive to run through these substitutions one more time
with a second handler.
Let's instead look at how StoreBuilder uses this information to build up
the type of the full dispatch object. Going back to this definition,
we'll see that StoreBuilder also has a second type parameter which
is the type of the dispatch object.
class StoreBuilder<STORE_STATE = {}, STORE_DISPATCH = {}> {
public addReducer<REDUCER_NAME extends string, REDUCER_STATE, REDUCER_DISPATCH>(
reducerName: REDUCER_NAME,
reducerBuilder: Reducer<REDUCER_STATE, REDUCER_DISPATCH>
): StoreBuilder<
STORE_STATE & { [name in REDUCER_NAME]: REDUCER_STATE },
STORE_DISPATCH & { [name in REDUCER_NAME]: REDUCER_DISPATCH }
> { /* ... */ }
Looking at the addReducer method again, we see that StoreBuilder
uses the exact same mechanism to extend the type of the store dispatch
object as it uses for the type of the store state. Using the Reducer
from above, let's look at how the types get inferred in this example
// TodoReducer has type Reducer<TodoItem[], { add(payload: string): void; }>
new StoreBuilder()
.addReducer('todos', TodoReducer)
Recall that REDUCER_NAME is inferred to be 'todos', REDUCER_STATE is inferred as TodoItem[]
and REDUCER_DISPATCH is inferred as { add(x: string): void; }. The resulting
StoreBuilder type will thus be
StoreBuilder<
{} & { [name in REDUCER_NAME]: REDUCER_STATE },
{} & { [name in REDUCER_NAME]: REDUCER_DISPATCH }
>
= StoreBulider<
{ [name in 'todos']: TodoItem[] },
{ [name in 'todos']: { add(payload: string): void; } }
>
= StoreBulider<
{ todos: TodoItem[] },
{ todos: { add(payload: string): void; } }
>
When invoking the build method, we will get a store with the following type
type StoreType = {
getState(): {
todos: TodoItem[];
};
dispatch: {
todos: {
add(x: string): void;
}
}
};
Which is the goal that we started out with. I'm not really sure how to conclude this post, but I think it is pretty cool that by leveraging three simple tricks we can get a type inferred where it is at first surprising that you can actually do this.