When I tried to use unistore instead of redux in one of my pet projects, I discovered that its TypeScript typings are not accurate.
For example,
import { h, Component, ComponentChild } from 'preact'; import { connect } from 'unistore/preact'; // Application State interface AppState { property: string; } // Sample Action function someAction(state: AppState, param: string): Partial<AppState> { return { /* ... */ }; } // Our component interface OwnProps { someProperty: string; } interface InjectedProps { injectedProperty: string; } interface ActionProps { someAction: (s: string) => void; } type Props = OwnProps & InjectedProps & ActionProps; type State = { /* ... */ }; class MyComponent extends Component<Props, State> { render(): null { this.props.someAction(this.props.injectedProperty); return null; } } function mapStateToProps(state: AppState): InjectedProps { return { injectedProperty: state.property }; } // const Extended = connect(mapStateToProps, { someAction })(MyComponent); export function Test(): ComponentChild { return <Extended someProperty="boo" />; }
Now, how are we supposed to invoke connect()
?
Attempt 1. Do not pass any type arguments:
connect(mapStateToProps, { someAction })(MyComponent);
This obviously fails:
test.tsx:45:59 - error TS2345: Argument of type 'typeof MyComponent' is not assignable to parameter of type 'ComponentConstructor| FunctionComponent | Component '. Type 'typeof MyComponent' is not assignable to type 'ComponentConstructor '. Types of parameters 'props' and 'props' are incompatible. Type 'InjectedProps' is not assignable to type 'Props'. Property 'someProperty' is missing in type 'InjectedProps' but required in type 'OwnProps'.
The compiler is unable to infer the type of MyComponent’s props.
Attempt 2. Pass <OwnProps, State, AppState, InjectedProps>
:
connect<OwnProps, State, AppState, InjectedProps>(mapStateToProps, { someAction })(MyComponent);
This will also fail:
test.tsx:46:101 - error TS2345: Argument of type 'typeof MyComponent' is not assignable to parameter of type 'ComponentConstructor| FunctionComponent | Component<...>'. Type 'typeof MyComponent' is not assignable to type 'ComponentConstructor '. Types of parameters 'props' and 'props' are incompatible. Type 'OwnProps & InjectedProps' is not assignable to type 'Props | undefined'. Type 'OwnProps & InjectedProps' is not assignable to type 'Props'. Property 'someAction' is missing in type 'OwnProps & InjectedProps' but required in type 'ActionProps'.
The compiler does not like that someAction
is missing. OK.
Attempt 3. Pass InjectedProps & ActionProps
as injected props type parameter.
connect<OwnProps, State, AppState, InjectedProps>(mapStateToProps, { someAction })(MyComponent);
This will fail in a different place:
test.tsx:48:82 - error TS2345: Argument of type '(state: AppState) => InjectedProps' is not assignable to parameter of type 'string | string[] | StateMapper'. Type '(state: AppState) => InjectedProps' is not assignable to type 'StateMapper '. Type 'InjectedProps' is not assignable to type 'InjectedProps & ActionProps'. Property 'someAction' is missing in type 'InjectedProps' but required in type 'ActionProps'.
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:
connect<OwnProps, State, AppState, any>(mapStateToProps, { someAction })(MyComponent);
This compiles, but now type checking suffers badly. For example, this will pass:
const Extended = connect<OwnProps, State, AppState, any>(mapStateToProps, { someAction })(MyComponent); export function Test(): ComponentChild { return <Extended someProperty={false} nonExisting={4} />; }
Attempt 5. Provide a different declaration for connect()
:
declare module 'unistore/preact' { import { AnyComponent, ComponentConstructor } from 'preact'; import { ActionCreator, StateMapper, Store } from 'unistore'; export function connect<T, S, K, I>( mapStateToProps: string | Array<string> | StateMapper<T, K, Partial<I>>, actions?: ActionCreator<K> | object, ): (Child: ComponentConstructor<T & I, S> | AnyComponent<T & I, S>) => ComponentConstructor<T | (T & I), S>; }
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,
connect<OwnProps, State, AppState, InjectedProps & ActionProps>(mapStateToProps, { someAction })(MyComponent);
now works.
The second issue I faced was the 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:
export function action1(): Partial<AppState> { /* ... */ } export function action2(state: AppState): void { /* ... */ } export function action3(state: AppState, param1: string, param2: number): Promise<Partial<AppState>> { /* ... */ }
We pass them to connect()
like this:
connect(mapStateToProps, { action1, action2, action3 });
However, connect()
passes them to our component bound to the application state; that is, our component will see them this way:
{ action1: () => Partial<AppState>; action2: () => void; action3: (param1: string, param2: number) => Promise<Partial<AppState>>; }
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:
import { h, Component, ComponentChild } from 'preact'; import { connect } from 'unistore/preact'; // Application State interface AppState { property: string; } // Sample Action // Note x before state function someAction(x: number, state: AppState, param: string): Partial<AppState> { return { /* ... */ }; } // Our component interface OwnProps { someProperty: string; } interface InjectedProps { injectedProperty: string; } interface ActionProps { someAction: (s: string) => void; nonExistingAction: () => number; } type Props = OwnProps & InjectedProps & ActionProps; type State = { /* ... */ }; class MyComponent extends Component<Props, State> { render(): null { this.props.someAction(this.props.injectedProperty); return null; } } function mapStateToProps(state: AppState): InjectedProps { return { injectedProperty: state.property }; } export const Extended = connect<OwnProps, State, AppState, InjectedProps & ActionProps>( mapStateToProps, { someAction, } )(MyComponent);
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
:
declare module 'unistore/preact' { import { AnyComponent, ComponentConstructor } from 'preact'; import { ActionCreator, ActionMap, StateMapper, Store } from 'unistore'; export function connect<T, S, K, I>( mapStateToProps: string | string[] | StateMapper<T, K, Partial<I>>, actions: ActionCreator<K> | ActionMap<K>, ): (Child: ComponentConstructor<T & I, S> | AnyComponent<T & I, S>) => ComponentConstructor<T | (T & I), S>; }
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
:
export function connect<T, S, K, I, A extends ActionMap<K>>(/* ... */)
Then, we will need to amend the type of actions
parameter:
export function connect<T, S, K, I, A extends ActionMap<K>>( mapStateToProps: string | string[] | StateMapper<T, K, I>, actions: ActionCreator<K> | A, )
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
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:
type TupleHead<T extends any[]> = T['length'] extends 0 ? never : T[0]; type TupleTail<T extends any[]> = T['length'] extends 0 ? never : ((...tail: T) => void) extends (head: any, ...tail: infer I) => void ? I : never;
This is how it works: if the 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, the 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 worked with LISP’s lists 🙂
With these helpers (TupleTail
alone is enough), it is very easy to create a helper for a bound action:
type MakeBoundAction<K, F extends (...args: any) => ReturnType<ActionFn<K>>> = ( ...args: TupleTail<Parameters<F>> ) => void;
We of course could use any
instead of ReturnType
and then get rid of K
, but this adds an extra safety check.
Now it is trivial to create a bound action function type:
type BoundActionFn<K> = MakeBoundAction<K, ActionFn<K>>;
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:
type ActionBinder<K, T extends object> = { [P in keyof T]: BoundActionFn<T[P]>; };
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:
declare module 'unistore/preact' { import { AnyComponent, ComponentConstructor } from 'preact'; import { ActionCreator, StateMapper, Store, ActionMap, ActionFn } from 'unistore'; type TupleTail<T extends any[]> = T['length'] extends 0 ? never : ((...tail: T) => void) extends (head: any, ...tail: infer I) => void ? I : never; type MakeBoundAction<K, F extends (...args: any) => ReturnType<ActionFn<K>>> = ( ...args: TupleTail<Parameters<F>> ) => void; type BoundActionFn<K> = MakeBoundAction<K, ActionFn<K>>; export type ActionBinder<K, T extends object> = { [P in keyof T]: BoundActionFn<T[P]>; }; export function connect<T, S, K, I, A extends ActionMap<K>>( mapStateToProps: string | string[] | StateMapper<T, K, I>, actions: ActionCreator<K> | A, ): ( Child: ComponentConstructor<T & I & ActionBinder<K, A>, S> | AnyComponent<T & I & ActionBinder<K, A>, S>, ) => ComponentConstructor<T | (T & I & ActionBinder<K, A>), S>; }
We will need some changes to the code:
-interface ActionProps { - someAction: (s: string) => void; +interface ActionProps extends ActionMap<AppState> { + someAction: typeof someAction; -type Props = OwnProps & InjectedProps & ActionProps; +type Props = OwnProps & InjectedProps & ActionBinder<AppState, ActionProps>; -export const Extended = connect<OwnProps, State, AppState, InjectedProps>( +export const Extended = connect<OwnProps, State, AppState, InjectedProps, ActionProps>(
Things I dislike but not sure how to get rid of:
ActionProps
has to either inherit fromActionMap
or have a compatible index signature;- we have to specify all five type parameters when calling
connect()
.