Anthony White
Anthony White

Reputation: 39

Typescript can't force generic to be passed

I want to have this utility which has a generic object Type and takes a key belonging to that type and the property associated with it like this:

export type StateBuilder = <StateSchema, Keys extends keyof StateSchema>(
  key: Keys,
  data: StateSchema[Keys]
) => StateSchema;

The issue is, I can't seem to pass the StateSchema Type. It always gives back the error

Type 'StateBuilder' is not generic. ts(2315)

Details

This is to be used inside a reducer function returned by a store generator. The main idea is to supply this function type to the reducer function using the store generator

import React, { ReactElement, ReactNode, createContext, useContext, useReducer } from 'react';

export type ReducerAction<Type = string> = {
  type: Type;
};
export type ReducerActionWithPayload<Type = string, Payload = unknown> = {
  type: Type;
  payload: Payload;
};

export type StateBuilder = <StateSchema, Keys extends keyof StateSchema>(
  key: Keys,
  data: StateSchema[Keys]
) => StateSchema;

export const generateStore = <Actions extends ReducerAction | ReducerActionWithPayload, State>(
  defaultValue: State,
  reducer: <Key extends keyof State, Property extends State[Key]>(
    state: State,
    action: Actions,
    stateBuilder: (key: Key, data: Property) => State
  ) => State
): {
  Provider: (props: { children: ReactNode }) => ReactElement;
  dispatcher: (action: Actions) => void;
  useStore: () => State;
} => {
  const store = createContext(defaultValue);
  const { Provider } = store;

  let dispatch: React.Dispatch<Actions>;

  const ProviderElm = (props: { children: ReactNode }): ReactElement => {
    const { children } = props;
    const [state, dispatcher] = useReducer(
      (state, action) =>
        reducer(state, action, (key, data) => ({
          ...state,
          [key]: data,
        })),
      defaultValue
    );
    dispatch = dispatcher;
    return <Provider value={state}>{children}</Provider>;
  };

  return {
    Provider: ProviderElm,
    dispatcher: (action: Actions) => dispatch && dispatch(action),
    useStore: () => useContext(store),
  };
};

Example

const DefaultStore = {
  token: null as Nullable<string>,
  isAuthenticated: false,
};

type ActionTypes = |
  ReducerAction<'AUTHENTICATION'> | 
  ReducerActionWithPayload<'SET_TOKEN', {token: string}>;

const { Provider: FormProvider, dispatcher: formDispatcher, useStore: useFormStore } = generateStore<
  ActionTypes,
  typeof DefaultStore
>(DefaultStore, (state = DefaultStore, action: ActionTypes, stateBuilder) => {
  switch (action?.type) {
    case 'AUTHENTICATION': {
      return stateBuilder('isAuthenticated', true);
    }
    
    case 'SET_TOKEN': {
      return stateBuilder('token', action.payload);
    }

    default:
      return state;
  }
});

export { FormProvider, formDispatcher, useFormStore };

Currently returning the error

Argument of type '"isAuthenticated"' is not assignable to parameter of type 'Key'.
  '"isAuthenticated"' is assignable to the constraint of type 'Key', but 'Key' could be instantiated with a different subtype of constraint '"isAuthenticated" | "token"'.ts(2345)

Upvotes: 0

Views: 595

Answers (2)

Todd Skelton
Todd Skelton

Reputation: 7239

I might have misunderstood what you are trying to do but it looks like you are trying to make a function to take a state and update the values on it. In that case, you still need to provide your original or starting state. Something like this might be what you are looking for.

You can just create a generic function and not have to worry about creating a generic type and then using that to create a function. You can use this utility function in any store.

const stateBuilder = <TState, TKey extends keyof TState>(state: TState, key: TKey, value: TState[TKey]) => ({ ...state, [key]: value });

let defaultValue = {
    foo: "hello",
    bar: 10
}

defaultValue = stateBuilder(defaultValue, "bar", 20);

console.log(defaultValue);

UPDATE

This should be what you are looking for. The main part is the StateBuilder type. It needed generics on both sides. One side to tell you what State it's working with and on the other side for the key/value since those are based on what you pass to the function.

import React, { ReactElement, ReactNode, createContext, useContext, useReducer } from 'react';

export type ReducerAction<Type = string> = {
    type: Type;
};

export type ReducerActionWithPayload<Type = string, Payload = unknown> = {
    type: Type;
    payload: Payload;
};

type StateBuilder<State> = <Key extends keyof State, Value extends State[Key]>(key: Key, value: Value) => State

export const generateStore = <Actions extends ReducerAction | ReducerActionWithPayload, State>(
    defaultValue: State,
    reducer: <Key extends keyof State, Value extends State[Key]>(
        state: State,
        action: Actions,
        stateBuilder: StateBuilder<State>
    ) => State
): {
    Provider: (props: { children: ReactNode }) => ReactElement;
    dispatcher: (action: Actions) => void;
    useStore: () => State;
} => {
    const store = createContext(defaultValue);
    const { Provider } = store;

    let dispatch: React.Dispatch<Actions>;

    const ProviderElm = (props: { children: ReactNode }): ReactElement => {
        const { children } = props;
        const [state, dispatcher] = useReducer(
            (state: State, action: Actions) =>
                reducer(state, action, (key, data) => ({
                    ...state,
                    [key]: data,
                })),
            defaultValue
        );
        dispatch = dispatcher;
        return <Provider value={state}>{children}</Provider>;
    };

    return {
        Provider: ProviderElm,
        dispatcher: (action: Actions) => dispatch && dispatch(action),
        useStore: () => useContext(store),
    };
};

export type DefaultStore = {
    token?: string;
    isAuthenticated?: boolean;
}

const defaultStore: DefaultStore = {
    token: "",
    isAuthenticated: false,
};

type ActionTypes = |
    ReducerAction<'AUTHENTICATION'> |
    ReducerActionWithPayload<'SET_TOKEN', { token: string }>;

const { Provider: FormProvider, dispatcher: formDispatcher, useStore: useFormStore } = generateStore<
    ActionTypes,
    DefaultStore
>(defaultStore, (state = defaultStore, action: ActionTypes, stateBuilder) => {
    switch (action?.type) {
        case 'AUTHENTICATION': {
            return stateBuilder('isAuthenticated', true); //typechecked
        }

        case 'SET_TOKEN': {
            return stateBuilder('token', action.payload.token); //typechecked
        }

        default:
            return state;
    }
});

export { FormProvider, formDispatcher, useFormStore };

Upvotes: 0

Daniel
Daniel

Reputation: 2777

I think what you're looking for is something like this:

export type StateBuilder<StateSchema, Keys extends keyof StateSchema> = (
  key: Keys,
  data: StateSchema[Keys]
) => StateSchema;

Here, the type StateBuilder<T, K> itself is generic and you have to specify both its type arguments to use it. The way you were declaring it, StateBuilder is an alias to a generic function type whose type variables are meant to always be inferred and cannot be specified from the StateBuilder name itself.

A more useful answer can be given if you present an example of how you want to use the type and what do you expect from it. Maybe do you want to specify only the object type and let TS to infer the key type?

Upvotes: 2

Related Questions