All Ruby on Rails Node JS Android iOS React Native Frontend Flutter QA

TypeScript, Preact and Unistore

Preact is a fast and lightweight alternative to React. Thanks to its size, it can be considered for such use cases as self-contained web widgets, progressive web applications, accelerated mobile pages, or applications where performance is prioritized. Unistore is a tiny centralized state container, which is often used together with preact instead of redux.

TypeScript is getting more and more popular in front-end development; one of the reasons for that is that it greatly simplifies the refactoring process: something that you have to do often when you are developing an application. More and more frameworks provide typings for TypeScript, and preact and unistore are not an exception.

However, the quality of typings differ: some of them exist only so that the package can be used in a TypeScript project, while others greatly simplify developer’s life by performing deep compile-time validation of parameters, which helps to avoid issues at run time.

Unistore’s typings are somewhere in between: they do provide basic validation, yet they leave a developer many chances to shoot themselves in the foot. In this article, we will show you how to make the development process with preact and unistore safer.

Let’s consider an 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 connect our component to the store?

One way is to pass no type arguments:

connect(mapStateToProps, { someAction })(MyComponent);

This will obviously not work because the compiler will be unable to infer the type of MyComponent’s props:

test.tsx:45:59 - error TS2345: Argument of type 'typeof MyComponent' is not assignable to parameter of type 'ComponentConstructor<InjectedProps, unknown> | FunctionComponent<InjectedProps> | Component<InjectedProps, unknown>'.

  Type 'typeof MyComponent' is not assignable to type 'ComponentConstructor<InjectedProps, unknown>'.

    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'.

OK, let us explicitly specify types:

connect<OwnProps, State, AppState, InjectedProps>(mapStateToProps, { someAction })(MyComponent);

This will also fail. This time, the compiler will not like that the someAction property is missing:

test.tsx:46:101 - error TS2345: Argument of type 'typeof MyComponent' is not assignable to parameter of type 'ComponentConstructor<OwnProps & InjectedProps, State> | FunctionComponent<OwnProps & InjectedProps> | Component<...>'.

  Type 'typeof MyComponent' is not assignable to type 'ComponentConstructor<OwnProps & InjectedProps, State>'.

    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'.

Let us try to add ActionProps to InjectedProps:
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<OwnProps, AppState, InjectedProps & ActionProps>'.

  Type '(state: AppState) => InjectedProps' is not assignable to type 'StateMapper<OwnProps, AppState, InjectedProps & ActionProps>'.

    Type 'InjectedProps' is not assignable to type 'InjectedProps & ActionProps'.

      Property 'someAction' is missing in type 'InjectedProps' but required in type 'ActionProps'.

Of course, there is always a workaround: for example, we can pass any instead of the last type:

connect<OwnProps, State, AppState, any>(mapStateToProps, { someAction })(MyComponent);

This will compile, but such a code defeats the purpose of type checking, because with this code you can pass non-existing properties, or even values of a different type:

const Extended = connect<OwnProps, State, AppState, any>(mapStateToProps, { someAction })(MyComponent);

export function Test(): ComponentChild {

    return <Extended someProperty={false} nonExisting={4} />;

}

So, we can see that the typings provided by unistore are far from the ideal. But we are developers, we can always take what’s wrong and make it better.

Let’s go.

Thanks to module augmentation, we can specify our own definitions. Let us start from 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>;

}

Our version differs from unistore’s one only in StateMapper type parameters: StateMapper<T, K, I> became StateMapper<T, K, Partial<I>>. Partial<> solves the issue with ActionProps: mapStateToProps() is now allowed to return only InjectedProps (this is what went wrong in our second attempt to make connect work).

So this will now work:

connect<OwnProps, State, AppState, InjectedProps & ActionProps>(mapStateToProps, { someAction })(MyComponent);

However, we can still improve this further.

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.

Now, consider we have these actions:

function action1(): Partial<AppState> { /* ... */ }

function action2(state: AppState): void { /* ... */ }

function action3(state: AppState, param1: string, param2: number): Promise<Partial<AppState>> { /* ... */ }

We pass them to connect like this:

connect(mapStateToProps, { action1, action2, action3 });

connect internally binds all actions to the application’s state, and our component sees those actions this way:

{

    action1: () => Partial<AppState>;

    action2: () => void;

    action3: (param1: string, param2: number) => Promise<Partial<AppState>>;

}

So, what is wrong with this? Now we have two sources of truth for our actions:

  1. The interface which describes actions as props for our component;
  2. 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 accept anything. For example, 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);

This is actually the expected result: because actions props have object type, the compiler is unable to verify that signatures of actions match the expected function signature: object can have any properties, of any type.

OK, let us take what’s wrong and make it better.

The first attempt will be to tell the compiler that connect should accept ActionMap<K> instead of object. This will catch the first error, when the action handler has an incompatible signature:

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

}

With this code, the compiler will complain about our sample code: Type 'AppState' is not assignable to type 'number' when it encounters x: number in the definition of someAction.

So, this fixes the type checking of actions: it is now impossible to pass a function with a non-conforming signature as an action. However, we still have two sources of truth for actions. In our example, the interface ActionProps has a nonExistingAction member, and actions passed to connect don’t have it.

This is not so easy to fix. We will have to add another type parameter to connect and 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: A | (store: Store<K>) => A,

)

However, ActionMap is incompatible with the actions accepted by the component, because unistore internally binds them to the application’s state, and therefore 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.

Luckily, TypeScript is a very powerful language. We can get arguments of a function as a tuple with the help of Parameters. After that, we need to remove the first element from the tuple. This can be done like this:

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;

If you have a background in a language like LISP, then the code is obvious. If not, here is a brief explanation. If the tuple’s length is greater than zero, its 0th 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.

With these type definitions, we can define a helper for a bound action:

type MakeBoundAction<K, F extends (...args: any) => ReturnType<ActionFn<K>>> = (

    ...args: TupleTail<Parameters<F>>

) => void;

There is an easier way: we can use any instead of ReturnType<ActionFn<K>>, and then we will not need K, but an extra safety check will never hurt.

The bound action’s function type can be defined like this:

type BoundActionFn<K> = MakeBoundAction<K, ActionFn<K>>;

Now, given an ActionMap, we need to create a BoundActionMap, which will contain BoundActionFn<K> instead of ActionFn<K>:

type ActionBinder<K, T extends object> = {

    [P in keyof T]: BoundActionFn<T[P]>;

};

We extend object here for two reasons:

  1. objects are more generic than ActionMaps, and they still can be passed to connect.
  2. ActionMap<K> extends object evaluates to true, and this guarantees that our definitions will not break the existing code.

The final version will be:

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: ((store: Store<K>) => A) | 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 have to make some changes to our example code, though:

-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>(

The code became much better. We fixed the bugs in the type definitions of unistore, and added type safety checks to the connect function. Also, our implementation ensures that we have a single source of truth for action types.

Our solution still has a few downsides:

  • ActionProps needs to either inherit from ActionMap or have a compatible index signature;
  • we need to specify all five type parameters when calling connect(), which makes the code more verbose.

However, our typings make the development and refactoring process more safe, and allow for easier detection of potential bugs.

We're building our future. Let's do this right - join us
New call-to-action
READ ALSO FROM TypeScript
Read also
Need a successful project?
Estimate project or contact us