When I tried to use unistore instead of redux in one of my pet projects, I discovered that its TypeScript typings are not very accurate.

For example,

Now, how are we supposed to invoke connect()?

Attempt 1. Do not pass any type arguments:

This obviously fails:

The compiler is unable to infer the type of MyComponent’s props.

Attempt 2. Pass <OwnProps, State, AppState, InjectedProps>:

This will also fail:

The compiler does not like that someAction is missing. OK.

Attempt 3. Pass InjectedProps & ActionProps as injected props type parameter.

This will fail in a different place:

The compiler now dislikes our state mapper, mapStateToProps(). The state mapper is given the application state and component’s own props, and it should return the injected props — at least, this is how the definition of unistore’s StateMapper reads.

Attempt 4. Use any as injected props type:

This compiles, but now type checking suffers badly. For example, this will pass:

Attempt 5. Provide a different declaration for connect():

This should go into a separate .d.ts file.

The only difference with unistore is that StateMapper<T, K, I> became StateMapper<T, K, Partial<I>>. Partial<I> solves the issue with ActionProps: mapStateToProps() is now allowed to return only InjectedProps, and that will be fine.

Thus,

now works.

The second issue I faced with was type safety of actions.

Actions in unistore can be defined either with a factory function (ActionCreator<K> = (store: Store<K>) => ActionMap<K>, where ActionMap is basically a Record<string, ActionFn<K>>), or as an action map. Every individual action (ActionFn) is a function, which accepts the current state as its first argument, and optional parameters, and returns updates to the state, a promise which resolves to updates to the state, or nothing at all: (state: K, ...args: any[]) => Promise<Partial<K>> | Partial<K> | void.

What is wrong with this? Say, we have these actions defined:

We pass them to connect() like this:

However, connect() passes them to our component bound to the application state; that is, our component will see them this way:

Now, we have two sources of truth for our actions: the interface which describes actions as props for our component, and the interface which describes actions as a map for connect(). connect() accepts actions as an object or a factory function, and the compiler is unable to match types (it will gladly accept anything).

Thus, this code will compile:

No surprise, as the compiler sees action props as object, and is therefore unable to verify that the type of the action matches the expected function signature.

What we need is to tell the compiler that connect() should accept ActionMap<K> instead of object:

This is better: the compiler complains that Type 'AppState' is not assignable to type 'number' when it sees the definition of someAction().

OK, we have just enabled type checking of actions. Now it is impossible to pass a function with a non-conforming signature as an action. But we still have two sources of truth regarding actions. In our example, the interface ActionProps has nonExistingAction() member, and actions passed to connect() don’t.

We will need to separate injected props from action props. For that, we will need an extra type parameter to connect:

Then, we will need to amend the type of actions parameter:

But then, ActionMap is not compatible with the actions accepted by the component — unistore internally binds them to the state, and thus their signature differs. We need to convert somehow (state: K, ...args: any[]) => Promise<Partial<K>> | Partial<K> | void into (...args: any[]) => void, and we need to take into account that the action may choose not to mention state in its parameter list.

In TypeScript, we can get function’s arguments as a tuple with the help of Parameters. Then, we need to remove somehow the first element from the tuple. This can be done with the following magic:

How this works: if tuple’s length is greater than zero, its zeroth element will be the head, otherwise the head is not defined. Same for the tail: if the tuple is empty, tail is not defined, otherwise if the tuple can be divided into a head (single element) and a tail (the rest of the elements), we return the tail. It is not difficult if you have ever workied with LISP’s lists 🙂

With these helpers (TupleTail alone is enough), it is very easy to create a helper for a bound action:

We of course could use any instead of ReturnType<ActionFn<K>> and then get rid of K, but this add an extra safety check.

Now it is trivial to create a bound action function type:

So far so good. Now we need, given an ActionMap, create a BoundActionMap, which will contain BoundActionFn<K> instead of ActionFn<K>. Fortunately, this is easy:

We extend object here because objects are more generic than ActionMap, and they can be passed to connect(). Moreover, ActionMap<K> extends object is true.

Now we have this:

We will need some changes to the code:

Things I dislike but not sure how to get rid of:

  • ActionProps has to either inherit from ActionMap or have a compatible index signature;
  • we have to specify all five type parameters when calling connect().
TypeScript, preact, and unistore
Tagged on:         

Leave a Reply

Your email address will not be published. Required fields are marked *