Strong Typed Redux
June 21, 2020
There are several known ways (most notably Redux Toolkit) of adding a typed wrapper around the redux API.
In our current project we’ve developed an in-house approach that makes it possible to create HOCs and hooks at the same time. This allowed us to switch from class components to functional component (and vice-versa) very easy, without having to rewrite the HOCs and hooks.
Besides trying to explain how this approach works. One aim of the article is to show how to develop powerful typed interfaces around existing Javascript libraries, or at least how we did it in this specific case.
TL;DR
Below is an example of how we’re currently using the pattern:
/**
* withConnectedTodos is a HOC
* useConnectedTodos is a hook
*/
export const [withConnectedTodos, useConnectedTodos] = bothConnect(
// The store is implicitly typed
({ todos: { todos } }) => ({
// Map the store to props
mainTodos: todos.filter((todo) => todo.state !== "cancelled"),
cancelledTodos: todos.filter((todo) => todo.state === "cancelled"),
}),
{
// Map some action creators to props
markAsDone: markAsDoneAction,
cancelTodo: cancelTodoAction,
saveTodo: saveTodoAction,
}
**;
/**
* Extract the inferred type without actually typing the props
* When using a functional component typing the props is not necessary but
* when using a class component, `WithConnectedTodos` can be passed as a generic parameter
*/
export type WithConnectedTodos = ExtractConnect<typeof withConnectedTodos>;
//// Usage in a Class component
/**
* Props that will be received from the parent component
*/
type OuterProps = { mainTitle: string };
/**
* All the props that the component will be available inside the component
* WithConnectedTodos is automatically inferred based so there's never the need to update
* its types manually
*/
type Props = OuterProps & WithConnectedTodos;
class Todos extends React.Component<Props, State> {
...
**
/**
* The return type will be `React.Component<OuterProps>`
* Will works exactly as the default `connect` HOC
*/
export default withConnectedTodos(Todos);
//// Usage in a functional component
function TodosFn(props: OuterProps) {
const [tempTodoTitle, setTodoTitle] = useState("");
/**
* todoProps will implicitly have a type of `WithConnectedTodos`
*/
const todoProps = useConnectedTodos();
...
}
The bothConnect
function works simliarly to the default connect
function from react-redux
,
but it doesn’t require explicit typing and creates both a HOC and a hook at the same time.
Also the ExtractConnected
conditional type can extract the type of the props that will
be available:
This allowed us to speed up some aspects of development by implicitly typing the HOCs and by allowing to switch to a functional component almost mechanically.
Intro to Redux
It is implied that you already know how redux works, if you don’t, this part should be like a very bad primer. The main premise of Redux is that you have a global object (not in the OOP sense, more like a record with data) usually called a Store and you act as though it is immutable. You can read values from it in your components and if you want to change it, you pass a message (basically a JSON object) that contains a type field and some other data. Based on that message you have some functions, called Reducers, that changes the Store.
There is much more to it and I recommend checking out the official documentation.
The sample project
The article is based on an internal article published inside the company I currently work for. To make the code more palatable, I’ve created a very small project which uses Redux. The logic of the project might seem a little ridiculous since it was not the aim of project (and also because it seems that I’m quite bad at creating random projects).
Simple types
Before going further imagine that in a different file you already defined the type of the Store and the types of the messages that can be passed to the store.
/// ./src/redux/index.ts
/**
* The main type of the store
*/
export type StoreType = {
todos: TodosStore
checkboxes: CheckboxesStore
}
/**
* A union type of all the possible messages
*/
export type MessageTypes = TodoMessages | CheckboxesMessages
const rootReducer = combineReducers<StoreType>({
todos: todosReducer,
checkboxes: checkboxReducer,
})
export const store = createStore(rootReducer)
Where TodoMessages
and CheckboxMessages
are union types
/// ./src/redux/ReduxTodos.ts
export type TodoMessages =
| {
type: "TODOS/ADD_TODO"
title: string
}
| {
type: "TODOS/MARK_AS_DONE"
id: string
}
| {
type: "TODOS/CANCEL_TODO"
id: string
}
Most Redux tutorials would recommend defining action types as constant first. That seems like a fear one would have in a world without static typing.
It looks kinda cryptic, but the basic idea is that a message can have a type
that can either be "TODOS/ADD_TODO"
, "TODOS/MARK_AS_DONE"
or "TODOS/CANCEL_TODO"
. And, for example if the type
is "TODOS/ADD_TODO"
the object should also contain a title
property of type string
. These types are called Algebraic Data Types (ADT).
The are very good at modelling different kinds of values, but unfortunatelly are not widly encountered in modern OOP languages.
Let’s get into the types.
Of course, the connect
function from react-redux
is kinda typed:
const connectedTodos = connect(
({ todos: { todos } }: StoreType) => ({
mainTodos: todos.filter(todo => todo.state !== "cancelled"),
cancelledTodos: todos.filter(todo => todo.state === "cancelled"),
}),
{
markAsDone: markAsDoneAction,
cancelTodo: cancelTodoAction,
saveTodo: saveTodoAction,
}
)
export default connectedTodos(Todos)
As you can see, we tell it that store
has the type StoreType
and now, if we happen to change something in the StoreType
, Typescript will scream until we fix everything we need to fix.
I guess this approach could work, but it seems too cumbersome, what if you forget to give store
its correct type. The connect
function should know by default that store
can only have one type. Unfortunately, the react-redux
developers didn’t know how our store will look and opening a PR each time we change something could be kinda annoying.
So here was the idea, what if, we create another function which will act exactly the same as connect
but would always know that store
has to have the type StoreType
and that all the function in the second argument have to return an MessageType
.
Below is the first attempt at solving this problem:
import { ComponentType } from "react"
import { connect } from "react-redux"
import { StoreType } from "."
export type UseConnected<KnownProps> = <TProps>(
Component: ComponentType<TProps & KnownProps>
) => ComponentType<TProps>
export type ExtractConnect<Something> = Something extends UseConnected<infer R>
? R
: never
/**
* This function works in the same way as the connect() from react-redux
* but is more strict, knows the type of the store, and also allows to extract it's types very
* easily using ExtractConnect
*/
export const useConnect: <PropsFromStore, PropsDispatch>(
mapStateToProps: (store: StoreType) => PropsFromStore,
mapDispatchToProps: PropsDispatch
) => UseConnected<PropsFromStore & PropsDispatch> = connect
There is some shady stuff going on, so let’s unpack it.
First of all we’ll have to dig into conditional types.
Conditional Types
T extends U ? X : Y
I recommend trying the Typescript Playground to check how things work.
Conditional types are usually useful when you create typings for things that were initially written in JS and have a more complex interface. Or, when you want to write indecipherable code and then write blog posts about it.
Basically we created the type OtherType
conditionally based on whether we could assign SomeType
to red
. You might ask yourself, what does extends
exactly mean in this context. In short, nobody really knows and you usually need to poke it until it makes sense. The way I finally started to think about it is that when writing A extends B ?...
you’re asking yourself (or the compiler) if you have some object of type A
can you pass it to a function that accepts a type B
? In practice though, you usually have to test things out and use the playground until things kinda work.
Inferring types
Imagine that you have a simple function:
function simpleFunction(): boolean {
return false
}
And somewhere else you have some variable (variable something
), and you want to say that variable something
should have the same type as the return type of the function simpleFunction
.
Of course, in this case you can just say that:
const something: boolean = true
But imagine that the function returned some other type and you don’t really want to import another type and memorize it and whatnot. You just want to say that the variable something
should always have the same type as the return type of simpleFunction
.
const something: ReturnType<typeof simpleFunction> = true
Somehow, ReturnType
can extract a type from a different type
type ReturnType<T> = T extends () => infer R ? R : never
This is (almost) the complete definition of ReturnType
. Generally when thinking about generics in TS, think about them like functions that act on types instead of trying to fit in the OOP model (that usually helps with sleep too).
This means that, if you give me some type T
and we somehow see that type T
is some kind of function that return a infer R
, then I return R
otherwise return never
. The actual definition of ReturnType
in the document
infer
only works with conditional types and the only (I think) use-case that I know of is to extract stuff from other types. These things only start to make sense after you try to use them and fail to do so for several months.
React.ComponentType<T>
Another important thing is how to say that something is a react component.
Let’s imagine that you wrote a component that besides the props that it expects from its parent, is also wrapped in a higher order component that passes it the prop loading
. You can express that like so:
function withLoading<TProps>(
SomeComponent: React.ComponentType<TProps & { loading: boolean }>
): React.ComponentType<TProps> {
return function WrappedComponent(mainProps: TProps) {
return <SomeComponent {...mainProps} loading={false} />
}
}
export default withLoading(ModalContainer)
There are other ways to do it, but this approach usually works the best.
A lot of APIs use
Omit<T,K>
,Exclud<T, U>
etc. But those approaches seem to cause more confusion down the road.
Basically the function withLoading
should be passed a component that has some props of type TProps
and also MUST have a prop loading
of type boolean
and will return another component that should be passed just props of type TProps
. The return component shouldn’t be passed the prop loading
since we send it internally.
It looks kinda cryptic, but it allows to model how components actually work.
withConnect
Once again, below is the first iteration of withConnect
.
Note that there were several ways of accomplishing the same interface.
The first part shows the actual first iteration of the function withConnect
(it was actually called useConnected
before React introduced hooks).
import { ComponentType } from "react"
import { connect } from "react-redux"
import { StoreType } from "."
export type UseConnected<KnownProps> = <TProps>(
Component: ComponentType<TProps & KnownProps>
) => ComponentType<TProps>
export type ExtractConnect<Something> = Something extends UseConnected<infer R>
? R
: never
/**
* This function works in the same way as the connect() from react-redux
* but is more strict, knows the type of the store, and also allows to extract it's types very
* easily using ExtractConnect
*/
export const withConnect = <PropsFromStore, PropsDispatch, TProps>(
mapStateToProps: (store: StoreType) => PropsFromStore,
mapDispatchToProps: PropsDispatch
): UseConnected<PropsFromStore & PropsDispatch> => <TProps>(
Component: ComponentType<TProps & PropsFromStore & PropsDispatch>
): ComponentType<TProps> => {
return connect(mapStateToProps, mapDispatchToProps)(Component as any)
}
And here is a shorter implementation that accomplishes the same goal.
export const withConnect: <PropsFromStore, PropsDispatch>(
mapStateToProps: (store: StoreType) => PropsFromStore,
mapDispatchToProps: PropsDispatch
) => UseConnected<PropsFromStore & PropsDispatch> = connect
Here is a third way of writing it. If think this one should be the most clear one:
export function withConnect<PropsFromStore, PropsDispatch>(
mapStateToProps: (store: StoreType) => PropsFromStore,
mapDispatchToProps: PropsDispatch
) {
return connect(mapStateToProps, mapDispatchToProps) as UseConnected<
PropsFromStore & PropsDispatch
>
}
Given some Component that has props of type type Props = ParentProps & PropsFromStore & PropsDispatch
, it will return a component that has props of type ParentProps
.
This means that PropsFromStore
and PropsDispatch
will be supplied by the HOC.
ParentProps
would be the only props that have to be supplied by the parent component
In this case, instead of {loading: boolean}
we have some types that are also generic.
Since connect
is a function that returns a function, things look ugly.
This HOC (Higher-order component) could just replace the usage of connect
since it doesn’t interfere with its definition in any way.
connect
is a function that gets a mapStateToProps
and a mapDispatchtoProps
and returns another function that should get a react component which should have some props that should include the PropsFromStore
and PropsDispatch
and it will return a component that has to be passed just the TProps
without the props from redux.
This is a handful, but the definition is much much terse than the one in the official package:
It goes on and on. If we decide to not use defaultProps
, we can get away with defining very specific types that are complex, but not extremely complex.
So why the need to define a UseConnected<T>
?
The idea was that instead of defining the types that should be given from the connect
function, I would like to have a inferred type so that I wouldn’t need to give the types explicitly. Check this out:
Now we can define the Props
without actually writing any types:
type Props = { mainTitle: string } & ExtractConnect<typeof connectedTodos>
Basically what I wanted to do is just defined the very aptly named connectedTodos
and then let TS figure out the types and set them using ExtractConnect<typeof connectedTodos>
.
If we don’t use UseConnected
TS tells us the whole type and it’s kinda hard to figure out what’s going on:
Also it’s more clear to implement the ExtractConnected type:
export type ExtractConnect<Something> = Something extends UseConnected<infer R>
? R
: never
If we decide to change what exactly is UseConnected
in the future, maybe it will be a more complex type of function, this thing will still work.
All in all, the goal was to trick TS into doing stuff for us, instead of us defining types all the time.
Function overloading
You probably noticed that we are forced to ALWAYS pass both mapStateToProps
and mapDispatchToProps
to withConnect
. That could be an ok down-side, but it was kinda annoying. It would be much easier if TS would take care of that for us. When using connect
you have 3 choices:
connect(mapStateToProps)
connect(null, mapDispatchToProps)
connect(mapStateToProps, mapDispatchToProps)
I couldn’t find a good solution for this so I just gave default arguments to the type parameters:
import { ComponentType } from "react"
import { connect } from "react-redux"
import { StoreType } from "../store"
export type UseConnected<OtherProps> = <TProps>(
Component: ComponentType<TProps & OtherProps>
) => ComponentType<TProps>
export type ExtractConnect<Something> = Something extends UseConnected<infer R>
? R
: never
/**
* This function works in the same way as the connect() from react-redux
* but is more strict, knows the type of the store, and also allows to extract it's types very
* easily using ExtractConnect
*/
export const withConnect = <PropsFromStore = {}, PropsDispatch = {}>(
mapStateToProps: null | ((store: StoreType) => PropsFromStore),
mapDispatchToProps?: PropsDispatch
) => {
return connect(mapStateToProps, mapDispatchToProps) as UseConnected<
PropsFromStore & PropsDispatch
>
}
But after some time I realized that function overloading can be used. I guess there could be other options (using unions of tuples for the arguments, but there were some problems with this approach at the time of writing the code).
// prettier-ignore
export function withConnect<PropsDispatch extends ObjectWithMessages = {}>(mapStateToProps: null, mapDispatchToProps: PropsDispatch): WithConnected<PropsDispatch>;
// prettier-ignore
export function withConnect<PropsFromStore extends {} = {}>(mapStateToProps: (store: StoreType) => PropsFromStore, mapDispatchToProps?: undefined): WithConnected<PropsFromStore>;
// prettier-ignore
export function withConnect<PropsFromStore extends {} = {}, PropsDispatch extends ObjectWithMessages = {}>(mapStateToProps: (store: StoreType) => PropsFromStore, mapDispatchToProps: PropsDispatch): WithConnected<PropsFromStore & PropsDispatch>;
export function withConnect<
PropsFromStore extends {} = {},
PropsDispatch extends ObjectWithMessages = {}
>(
mapStateToProps: null | ((store: StoreType) => PropsFromStore),
mapDispatchToProps?: PropsDispatch
) {
return connect(mapStateToProps, mapDispatchToProps)
}
The idea behind function overloads is that, when creating types for some functions, you can say that if the function gets a particular parameter it will return a different output, for example:
It’s not really an overload in the OOP sense, you still have just one function, but you can define multiple definitions for it and TS will handle the rest. The problem is that the function implementation has to support a union of all the possible arguments for all its overloads.
In the case of withConnect
I was trying to say that you can call it in 3 different ways (as mentioned before) and it will act a little bit different in each case. If you didn’t pass anything for the second argument, the function will return a simpler definition instead of doing something silly.
The time of hooks
When hooks appeared on the horizong I realized that this approach could be extended further. Instead of using connect
they adviced to use useActions
and useSelector
. Defining wrappers for these was simpler since you didn’t need overloads and all kinds of silly type-level programming.
export function useSelect<T extends {} = {}>(
mapStateToProps: (store: StoreType) => T
): T {
return useSelector(mapStateToProps)
}
export function useActions<T extends ObjectWithMessages = {}>(actions: T): T {
const dispatch = useDispatch()
const boundDispatches = bindActionCreators(actions, dispatch)
return boundDispatches
}
Of course react-redux
removed useActions
and advised the usage of useDispatch
:
But that shouldn’t stop us from creating an easy API.
I wanted to use the hook things just as I was already using the HOC things. Even withConnect
and useActions
seemed like too many functions. I wanted to define things just as I was defining them for HOCS.
So I created a function that could be passed the same arguments as withConnect
:
// prettier-ignore
export function createUseConnect<PropsDispatch extends ObjectWithMessages = {}>(mapStateToProps: null, mapDispatchToProps: PropsDispatch): UseConnected<PropsDispatch>;
// prettier-ignore
export function createUseConnect<PropsFromStore extends {} = {}>(mapStateToProps: (store: StoreType) => PropsFromStore, mapDispatchToProps?: undefined): UseConnected<PropsFromStore>;
// prettier-ignore
export function createUseConnect<PropsFromStore extends {} = {}, PropsDispatch extends ObjectWithMessages = {}>(mapStateToProps: (store: StoreType) => PropsFromStore, mapDispatchToProps: PropsDispatch): UseConnected<PropsFromStore & PropsDispatch>;
export function createUseConnect<
PropsFromStore extends {} = {},
PropsDispatch extends ObjectWithMessages = {}
>(
mapStateToProps: null | ((store: StoreType) => PropsFromStore),
mapDispatchToProps?: PropsDispatch
) {
// This is done for performance reasons so that redux does not rerender the methods that don't care about some stuff
if (mapStateToProps && mapDispatchToProps) {
return () => ({
...useSelect(mapStateToProps),
...useActions(mapDispatchToProps),
})
}
if (mapStateToProps && !mapDispatchToProps) {
return () => useSelect(mapStateToProps)
}
if (mapDispatchToProps && !mapStateToProps) {
return () => useActions(mapDispatchToProps)
}
return () => ({})
}
Just as you would do something like
const withWhatever = withConnect(null, {whatever => {type: 'something}});
and then wrap a component with it, I wanted to create a hook in the same way:
const useWhatever = createUseConnect(null, {whatever => {type: 'something}});
And then use useWhatever
as a hook inside a function component and not think about it.
The definition is not perfect of course because Typescript thinks that we have 4 options for the function when in reality we need to support just 3 cases. I didn’t find a beautiful way to express this so I just returned an empty function at the end of createUseConnect
.
bothConnect
As you might observe, the HOC and the hooks are defined in almost the same way so I was thinking, why should I define the same thing twice, it would be cooler if we would not need to repeat ourselves and be more DRY.
export type WithModalProps = ExtractConnect<typeof withModal>
export const [withModal, useModal] = bothConnect(null, {
openModal,
closeModal,
})
Here both the HOC withModal
and the hook useModal
are created at the same time. And the type WithModalProps
is extracted automagically from one of them. This seems like the minimal amount of code necessary.
bothConnect
was more annoying to implement since TS doesn’t seem to like overloaded functions very, much even though it seems clear that they should work. I could of course used any
or @ts-ignore
but real men don’t do it like that.
// prettier-ignore
export function bothConnect<PropsDispatch extends ObjectWithMessages = {}>(mapStateToProps: null, mapDispatchToProps: PropsDispatch): BothTypeConnect<PropsDispatch>;
// prettier-ignore
export function bothConnect<PropsFromStore extends {} = {}>(mapStateToProps: (store: StoreType) => PropsFromStore): BothTypeConnect<PropsFromStore>;
// prettier-ignore
export function bothConnect<PropsFromStore extends {} = {}, PropsDispatch extends ObjectWithMessages = {}>(mapStateToProps: (store: StoreType) => PropsFromStore, mapDispatchToProps: PropsDispatch): BothTypeConnect<PropsFromStore & PropsDispatch>;
export function bothConnect<
PropsFromStore extends {} = {},
PropsDispatch extends ObjectWithMessages = {}
>(
mapStateToProps: null | ((store: StoreType) => PropsFromStore),
mapDispatchToProps?: PropsDispatch
) {
if (mapStateToProps && mapDispatchToProps) {
return [
withConnect(mapStateToProps, mapDispatchToProps),
createUseConnect(mapStateToProps, mapDispatchToProps),
]
}
if (mapStateToProps && !mapDispatchToProps) {
return [
withConnect(mapStateToProps, mapDispatchToProps),
createUseConnect(mapStateToProps, mapDispatchToProps),
]
}
if (mapDispatchToProps && !mapStateToProps) {
return [
withConnect(mapStateToProps, mapDispatchToProps),
createUseConnect(mapStateToProps, mapDispatchToProps),
]
}
// INFO: Practically the inner functions already check for null/undefined and theoretically it would've been
// necessary just 1 single line of code but Typescript (or me) cannot understand how to handle inner overloaded
// functions and as such this hacky way was needed
throw new Error("bothConnect passed both arguments that are null/undefined")
}
As you can see, TS is very nice and forces me to define the same code several times, but alas. That is the price for having a nice bathroom, sometimes the plumbing gets messy.
I also used a tuple because the return values didn’t really have any meaningful implicit names.
Some things could be tidied up, but it kinda worked and I really needed to do some task and stop spending time on playing with types.
Final words
Not all TS is like this. It usually happens when an older JS API is given new types, things get messy real fast. But it seems to be easier to keep the plumbing in a single place instead of letting it run all over the bathroom.
In the next part I will touch on more advanced problems that have arisen after some usage:
- How to type
redux-thunk
- How to memoize functions returned from
useActions
so that components don’t reload all the time