tiny-atom icon indicating copy to clipboard operation
tiny-atom copied to clipboard

Typescript typings

Open olee opened this issue 6 years ago • 2 comments

Hi, I wrote some (WIP) typescript typings which can properly handle tiny-atom with react hook bindings:

declare module 'tiny-atom' {

    /** Type to create object like { foo: never, bar: "bar" } if bar matches condition and foo not */
    type FilterKeys<Base, Condition> = { [Key in keyof Base]: Base[Key] extends Condition ? Key : never };

    /** Return type containing distinction of all values of T */
    type Values<T> = T[keyof T];

    export interface DispatchFunction<A = any> {
        <AA extends ActionsWithoutPayload<A>>(action: AA): void;
        <AA extends ActionsWithPayload<A>>(action: AA, payload: ActionHandlerPayload<A[AA]>): void;
    }

    export type ObserveFunction<ATOM> = (atom: ATOM) => void;

    export type UnobserveFunction = () => void;

    export type AtomState<ATOM> = ATOM extends Atom<infer S, any> ? S : never;

    export type AtomActions<ATOM> = ATOM extends Atom<any, infer A> ? A : never;

    export type ActionHandlerPayload<F> =
        F extends ActionHandlerWithoutPayload<any> ? undefined :
        F extends ActionHandlerWithPayload<any, infer P> ? P :
        never;

    export type AtomBoundAction<F> =
        F extends ActionHandler<any, undefined> ? () => void :
        F extends ActionHandler<any, infer P> ? (payload: P) => void :
        never;

    export type ActionsWithoutPayload<A> = Values<FilterKeys<A, ActionHandlerWithoutPayload>>;
    export type ActionsWithPayload<A> = Values<FilterKeys<A, ActionHandlerWithPayload>>;

    export type AtomBoundActions<ATOM> = {
        [key in keyof AtomActions<ATOM>]: AtomBoundAction<AtomActions<ATOM>[key]>;
    };

    export interface Atom<S = any, A = any> {
        dispatch: DispatchFunction;
        fuse: <S2, A2>(state: S2, actions: A2) => Atom<S & S2, A & A2>;
        get: () => S;
        observe: (cb: ObserveFunction<this>) => UnobserveFunction;
        set: (partial: Partial<S>) => void;
        swap: (state: S) => void;
    }

    export interface ActionAtom<S> {
        dispatch: DispatchFunction;
        get: () => S;
        set: (partial: Partial<S>) => void;
        swap: (state: S) => void;
    }

    type ActionHandlerWithoutPayload<S = any> = (store: ActionAtom<S>) => any;
    type ActionHandlerWithPayload<S = any, P = any> = (store: ActionAtom<S>, payload: P) => void;
    export type ActionHandler<S = any, P = any> = ActionHandlerWithPayload<S, P> | ActionHandlerWithoutPayload<S>;

    export default function createAtom<S = {}, A = {}>(initialState: S, actions: A): Atom<S, A>;
}

declare module 'tiny-atom/react' {
    import React from 'react';
    import { Atom } from 'tiny-atom';

    export const Provider: React.SFC<{ atom: Atom }>;
    export const Consumer: React.Consumer<Atom>;
    export function connect(...args: any[]): any;
}

declare module 'tiny-atom/react/hooks' {
    import {
        Atom,
        AtomState,
        AtomActions,
        AtomBoundActions,
        DispatchFunction,
    } from 'tiny-atom';

    export interface UseAtomOptions {
        /**
         * If the connection is pure, the mapped props are compared to previously mapped props for avoiding rerenders. 
         * Set this to false to rerender on any state change.
         * default: true
         */
        pure?: boolean;
        /**
         * By default, the change listeners are debounced such that at most one render occurs per frame. 
         * Set to true to rerender immediately on change state.
         * default: false
         */
        sync?: boolean;
        /**
         * Use this to control if the connector subscribes to the store or simply projects the state on parent rerenders.
         * default: true in the browser, false on the server
         */
        observe?: boolean;
    }

    // export function useAtom<ATOM extends Atom, MAP extends (state: AtomState<ATOM>) => any = (state: AtomState<ATOM>) => any>(map?: MAP, options?: UseAtomOptions): ReturnType<MAP>;
    // export function useAtom<MAP extends (state: any) => any>(map?: MAP, options?: UseAtomOptions): ReturnType<MAP>;
    export function useAtom<S, R>(map?: (state: S) => R, options?: UseAtomOptions): R;
    export function useActions<ATOM extends Atom>(): AtomBoundActions<ATOM>;
    export function useDispatch<ATOM extends Atom>(): DispatchFunction<AtomActions<ATOM>>;
}

Here's an example of them being used:

import React from 'react';
import createAtom, { ActionAtom, AtomState } from 'tiny-atom';
import { Provider } from 'tiny-atom/react';
import { useAtom, useActions, useDispatch } from 'tiny-atom/react/hooks';

interface State {
    count: number;
}

const initialState: State = {
    count: 0,
};

const atomActions = {
    increment: ({ get, set }: ActionAtom<State>, n: number) => {
        const count = get().count;
        set({ count: count + n });
    },
    decrement: ({ get, set }: ActionAtom<State>, n: number) => {
        const count = get().count;
        set({ count: count - n });
    },
    /** payload-less action */
    incrementOne: ({ get, set }: ActionAtom<State>) => {
        set({ count: get().count + 1 });
    },
    log: ({ }: ActionAtom<State>, msg: string) => {
        console.log(msg);
    },
};

const atom = createAtom(initialState, atomActions);

/** utility function to simplyfy useAtom calls */
function useAtomTyped<ATOM>() {
    return <MAP extends (state: AtomState<ATOM>) => any>(map?: MAP) =>
        useAtom<AtomState<ATOM>, ReturnType<MAP>>(map);
}

const TestTinyAtom = () => {
    // const state = useAtom((s: AtomState<typeof atom>) => s.count);
    const state = useAtomTyped<typeof atom>()(s => s.count);
    const actions = useActions<typeof atom>();
    const dispatch = useDispatch<typeof atom>();
    // even correctly handles dispatch ('incrementOne') needing no args and dispatch ('increment', 1) requiring a number argument
    // so for example dispatch('increment') or dispatch('decrement', 'test') will correctly throw build errors! ;-)
    return (
        <div>
            <p>Counter = {state}</p>
            <button onClick={() => actions.incrementOne()}>+</button>
            <button onClick={() => actions.decrement(1)}>-</button>
            <button onClick={() => dispatch('decrement', 2)}>--</button>
        </div>
    );
};

export default () => (
    <Provider atom={atom}>
        <TestTinyAtom />
    </Provider>
);

I think it might be worth including typings in this project as it greatly improves usability. Also it properly handles payload-less dispatch calls which was quite difficult to figure out 😉

These are still not finished completely (connect missing and some other small stuff for createAtom), but I want to post these here for now so others looking for this library can use them already.

olee avatar Jan 02 '19 21:01 olee

Uuu, this is awesome, will have a play soon.

Does this autocomplete the available actions and their signatures in useActions or when calling them in the code? And does it do it for dispatch too?

KidkArolis avatar Jan 03 '19 16:01 KidkArolis

Sure ;-) It works properly for actions, but it's not possible for dispatch because it works through overloads. image

olee avatar Jan 05 '19 02:01 olee