How to type react-redux style HOC in Typescript
Even though render props and hooks are all the rage recently, I do not think HOCs as a pattern will go away anytime soon. Typing HOCs though can be a pretty daunting task especially when you go into connect style HOC where you have a HOC factory function, that is configured with function accessing props of the wrapped component.
Obviously, the best way to find out how to do it is to look at how it was already done. Redux has a good set of declaration in DefinitelyTyped and that was exactly where I looked at. If it was easy to understand what was going on there though I would not decide to write this, so I hope this explanation will help you grasp the concepts faster.
TLDR: If you just want to see the code, here it is, otherwise, continue to step by step explanation.
First, let’s see what we want to do without any typing information in plain JavaScript
This is a simplification compared to real connect function for sake of clarity of this example. But it contains all important parts that make typing such function hard and it should be easy to see how you can build on top of it and add more functionality later on.
So, in essence, this is an injector, but a dynamic one. What it injects is dependant on the result of mapProps
function. Also what mapProps
function should get as an argument is dependant on the WrappedComponent
because it will be its props minus whatever we are injecting.
So lets first type the mapProps
function. This should be easy enough:
We do not know the exact type of neither OwnProps
nor MappedProps
so we need to make them generic parameters:
Again compared to react-redux typings this is fairly simple. The reason is that in react-redux you also have mapDispatchToProps
, mergeProps
, and options
and there are overloads for various combinations of those parameters as not all are required.
So what does this first function return? First let’s figure out what is the type of the WrappedComponent
. We already have OwnProps
and MappedProps
defined there and we know that our WrappedComponent
will get both so let’s try that first:
Now it is easy to see what will be the final type as we just subtract MappedProps
leaving us with OwnProps
:
This kinda works but this is nowhere near the complexity of react-redux typing. So let’s see how this works and what issues there are with this simple approach.
First, it does not handle defaultProps
in any way. If our WrappedComponent
has some defaultProps
defined, its props will be defined as non-optional but are not required when using the component. At the same time if we do not do anything special, at the moment mapProps
won’t get any defaults so OwnProps
are not the same as inside the WrappedComponent
.
This is technically correct as the Connected
component does not have any defaultProps
. If you want to use num
as non-optional in mapProps
you need to pass it to Connected
or you could define it as optional and handle it inside the mapProps
.
One way to handle this is to hoist the defaultProps
from WrappedComponent
.
There are few important changes:
- We extracted the WrappedComponent type into generic parameter
<C extends React.ComponentType<MappedProps & OwnProps>>
so we can reuse it later. - The ConnectedComponent type is
<React.ComponentType<JSX.LibraryManagedAttributes<C, OwnProps>>
. TheJSX.LibraryManagedAttributes
part is the type that handles defaultProps in a way that it makes them optional in result type. - We use
ConnectedComponent.defaultProps = WrappedComponent.defaultProps as any
to hoist our defaults. Theany
part is unfortunate but necessary here. To be correct we should actually pick only defaults forOwnProps
but we do not know at this time what they are. So we are assigning defaults forOwnProps + MappedProps
here. A side effect of this is that we will have default values for props that are not defined in theConnected
component which is not ideal. - Our
ConnectedComponent
hasprops: any
. This is a side effect of usingC
as a generic parameter instead of exact type and I am not exactly sure why. If anybody has an idea please leave a comment. In any case, you do not know what props you will get anyway so you do not usually need to have type there, you are passing it further down. If you needed some props for internal working of the HOC, you could add another type for internal props and make theConnectedComponent
require also those.
Mind that this is different from react-redux as it is not hoisting your defaultProps but it is using the JSX.LibraryManagedAttributes
with a subtle issue. I do not thing hoisting the defaultProps
is the best approach so I will continue without it but we will keep using the JSX.LibraryManagedAttributes
type because even without hoisting the defaultProps
it is needed to properly handle defaults in WrappedComponent
.
The second issue this still has is that we are not actually inferring anything from the WrappedComponent
. This can be ok for some use-cases but can be tedious because you need to properly define both MappedProps
and OwnProps
or always type the mapProps
arguments when using the HOC which can be awkward when you do not need to use the props there.
Even if you do that, you created a function that takes only a special kind of WrappedComponent
and so you will get type errors when wrapping the component if it does not match MappedProps
and OwnProps
.
This additional strictness could be seen as a personal preference but let’s try going further and figure out how to make it bit easier to use.
So first we need to figure out how to get real props of the WrappedComponent
instead of always assuming it will be OwnProps + MappedProps
. For that, we will use infer
keyword which you can use in TypeScript although only in extend clause of a conditional type. react-redux uses a nice little helper for getting props from a ComponentType
:
And we will need also a small utility type Omit
:
So now we can use them instead of OwnProps
in WrappedComponent
definition:
WrappedComponent
now extends React.ComponentType<GetProps<C>>
which is kinda obvious but it means it is not strictly tied to what is defined in mapProps
as OwnProps
anymore.
As I mentioned, we are still using JSX.LibraryManagedAttributes
here even though we are not hoisting defaultProps
. The reason is that whatever is left after we remove MappedProps
from WrappedComponent
props needs to take defaultProps
into account. At the same time, we add & OwnProps
, so that we still type check what you require in mapProps
and make sure it is passed into the ConnectedComponent
, without taking defaultProps
into account for them.
One thing that can be seen as a drawback is that MappedProps
can have keys which are not expected by the WrappedComponent
which in some cases can be a sign of a bug or just forgotten code but is in sync with how react-redux works.
So are we done now? What will happen if we inject the right props but of the wrong type? Right now our HOC will let that slide because we just omit our MappedProps
keys without any check for the types of the keys.
In react-redux they also thought about that. They use another helper type:
The main point is that in case theDecorationTargetProps
cannot accept a type of key from InjectedProps
it will return type where the offending key is of type from InjectedProps
. This creates a type that needs to match our WrappedComponent
props otherwise we would be injecting wrong props into our component. Our generic parameter for WrappedComponent
now changes to:
So at this moment, we will get a type error when we try to wrap component which cannot handle the type of injected props.
So now we are done and it looks like this.
Side note on one difference that can be spotted if you look at the react-redux typing.
The return type of the final component in react-redux is in essence this:
With Shared
defined as:
This should omit only keys which are assignable to the WrappedComponent
. One issue is that even though this returns type where keys that do not match types in WrappedComponent
get mapped to never
, when used with keyof
even never
keys are returned and so we remove also non-matching keys from the requirements of the final component which seems like a bug.
You could use a modified version of Shared
type:
This returns shared keys which types also match directly so your final component would be:
But as we are already getting an error from our previous use of Matching
type this won’t get used, because we do not allow wrapping of a component that cannot take the proper type of props. You could remove the Matching
part from the WrappedComponent
definition and keep Shared
instead. That would mean you get an error on type mismatch at the place you are using the final ConnectedComponent
, saying that you need to supply prop even though it is supplied in mapProps
function (due to the mismatch in types of that injected prop). This seems to me as more confusing so I prefer the solution with Matching
.
And now it is really all. Hope this helps and if you find any issue please comment and I will try to respond with something meaningful. Also, this was all done and tested with Typescript 3.2.1 so check your version first if something does not work as advertised here.