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 {
    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 {
    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 ;
}

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(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(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(mapStateToProps, { someAction })(MyComponent);

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

const Extended = connect(mapStateToProps, { someAction })(MyComponent);
export function Test(): ComponentChild {
    return ;
}

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(
        mapStateToProps: string | Array | StateMapper>,
        actions?: ActionCreator | object,
    ): (Child: ComponentConstructor | AnyComponent) => ComponentConstructor;
}

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(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 { /* ... */ }
export function action2(state: AppState): void { /* ... */ }
export function action3(state: AppState, param1: string, param2: number): Promise> { /* ... */ }

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;
    action2: () => void;
    action3: (param1: string, param2: number) => Promise>;
}

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 {
    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 {
    render(): null {
        this.props.someAction(this.props.injectedProperty);
        return null;
    }
}

function mapStateToProps(state: AppState): InjectedProps {
    return {
        injectedProperty: state.property
    };
}

export const Extended = connect(
    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(
        mapStateToProps: string | string[] | StateMapper>,
        actions: ActionCreator | ActionMap,
    ): (Child: ComponentConstructor | AnyComponent) => ComponentConstructor;
}

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>(/* ... */)

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

export function connect>(
    mapStateToProps: string | string[] | StateMapper,
    actions: ActionCreator | 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> | Partial | 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:

type TupleHead = T['length'] extends 0 ? never : T[0];
type TupleTail = 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 ReturnType>> = (
    ...args: TupleTail>
) => 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 = MakeBoundAction>;

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 = {
    [P in keyof T]: BoundActionFn;
};

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['length'] extends 0
        ? never
        : ((...tail: T) => void) extends (head: any, ...tail: infer I) => void
        ? I
        : never;

    type MakeBoundAction ReturnType>> = (
        ...args: TupleTail>
    ) => void;
    type BoundActionFn = MakeBoundAction>;

    export type ActionBinder = {
        [P in keyof T]: BoundActionFn;
    };

    export function connect>(
        mapStateToProps: string | string[] | StateMapper,
        actions: ActionCreator | A,
    ): (
        Child: ComponentConstructor, S> | AnyComponent, S>,
    ) => ComponentConstructor), S>;
}

We will need some changes to the code:

-interface ActionProps {
-    someAction: (s: string) => void;
+interface ActionProps extends ActionMap {
+    someAction: typeof someAction;
 
-type Props = OwnProps & InjectedProps & ActionProps;
+type Props = OwnProps & InjectedProps & ActionBinder;
 
-export const Extended = connect(
+export const Extended = connect(

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 *