2

I'm following an action/reducer pattern for React put forth by Kent Dodds and I'm trying to add some type safety to it.

export type Action = 
    { type: "DO_SOMETHING", data: { num: Number } } |
    { type: "DO_SOMETHING_ELSE", data: { nums: Number[] } };

type Actions = {
    [key in Action["type"]]: (state: State, data: Action["data"]) => State;
};

const actions: Actions = {
   DO_SOMETHING: (state, data) => {
       return { nums: [data.num] }; // Type error
   },
   DO_SOMETHING_ELSE: (state, data) => {
       return { nums: data.nums }; // Type error
   }
};

This code is nice because it ensures the actions object contains all the action types listed in the Action union type as well as providing type safety when trying to dispatch an action. The problem comes in when trying to access members of data.

Property 'num' does not exist on type '{ num: Number; } | { nums: Number[]; }'.
  Property 'num' does not exist on type '{ nums: Number[]; }'.

But, if I do this:

export type Action = 
    { type: "DO_SOMETHING", data: { num: Number } } |
    { type: "DO_SOMETHING_ELSE", data: { nums: Number[] } };

type Actions = {
    [key in Action["type"]]: (state: State, action: Action) => State;
};

const actions: Actions = {
   DO_SOMETHING: (state, action) => {
       if (action.type !== "DO_SOMETHING") return state;
       return { nums: [action.data.num] }; // No more type error
   },
   DO_SOMETHING_ELSE: (state, action) => {
       if (action.type !== "DO_SOMETHING_ELSE") return state;
       return { nums: action.data.nums }; // No more type error
   }
};

Now TypeScript knows action.data is the union type that matches the explicit action.type. Is there a cleaner way to do this without having to inline all the actions into a big switch statement?

PS - Here is the full playground snippet I've been using to test all this.

CatDadCode
  • 58,507
  • 61
  • 212
  • 318
  • Do your actions have a consistent top struct of these two keys: `{ type: string; data: any }`? – hackape Sep 16 '21 at 01:25
  • 1
    FYI it looks like you’re recreating a lot of the logic of @reduxjs/toolkit, so maybe you can use that. It has very strong types. – Linda Paiste Sep 16 '21 at 15:52

2 Answers2

4

You were very close.

This line Action['data'] in (state: State, data: Action["data"]) => State; was incorrect.

Action['data'] should have been binded with key property.

See this example:

type State = {
    nums: number[]
}
export type Action =
    | { type: "DO_SOMETHING", data: { num: number } }
    | { type: "DO_SOMETHING_ELSE", data: Pick<State, 'nums'> };

type Actions = {
    [Type in Action["type"]]: (state: State, data: Extract<Action, { type: Type }>['data']) => State;
};

const actions: Actions = {
    DO_SOMETHING: (state, data) => ({ nums: [data.num] }),
    DO_SOMETHING_ELSE: (state, data) => ({ nums: data.nums })
};

Playground

I have used Type instead of key since we are iterating through types property.

Extract - expects two arguments. First - a union, second - type it should match. Treat it as an Array.prototype.filter for unions.

P.S. Please avoid using constructor types like Number, use number instead.

Interface Number corresponds to number as an object and Number as a class corresponds to class constructor:

interface Number {
    toString(radix?: number): string;
    toFixed(fractionDigits?: number): string;
    toExponential(fractionDigits?: number): string;
    toPrecision(precision?: number): string;
    valueOf(): number;
}

interface NumberConstructor {
    new(value?: any): Number;
    (value?: any): number;
    readonly prototype: Number;
    readonly MAX_VALUE: number;
    readonly MIN_VALUE: number;
    readonly NaN: number;
    readonly NEGATIVE_INFINITY: number;
    readonly POSITIVE_INFINITY: number;
}

declare var Number: NumberConstructor;

UPDATE

Code snippet taken from your shared example:

function reducer(state: State, action: Action): State {
    
    /**
     * Argument of type '{ num: Number; } | { nums: Number[]; }'
     *  is not assignable to parameter of type '{ num: Number; } & { nums: Number[]; }'.
     */
    const newState = actions[action.type](state, action.data);
    return { ...state, ...newState };
}

You are getting this error because:

multiple candidates for the same type variable in contra-variant positions causes an intersection type to be inferred.

Hence, second argument of actions[action.type] function is an intersection of all arguments of Actions.

Here you have an answer with more examples and here you can read my article.

You can add condition statement:

const reducer = (state: State, action: Action): State => {
    if(action.type==="DO_SOMETHING"){
        const newState = actions[action.type](state, action.data); // ok
    }
// ....
}

But this is bad solution because you have a lot of actions. This is not how we handle it.

Here you can find similar example.

So you have two options, except the previous.

First one, just use type assertion - as and move on.

Second one:


type Builder<A extends { type: PropertyKey, data: any }> = {
    [K in A["type"]]: (state: State, data: Extract<A, { type: K }>["data"]) => State;
};


const reducer = <
    Type extends PropertyKey,
    Data,
    Act extends { type: Type, data: Data },
    Acts extends Builder<Act>
>(actions: Acts) =>
    (state: State, action: Act): State => {
        const newState = actions[action.type](state, action.data);
        return { ...state, ...newState };
    }

As you might have noticed I have infered each property of Action and made strict relationship between actions and action.

Full example

P.S. reducer is curried, so don't forget to pass it as reducer(actions) to useReducer.

1
type ActionDataMap = {
    DO_SOMETHING: { num: Number };
    DO_SOMETHING_ELSE: { nums: Number[] } 
};

type ActionType = keyof ActionDataMap
type ActionsMap = {
  [K in ActionType]: { type: K; data: ActionDataMap[K] }
}

// this will generate the union:
type Action = ActionsMap[ActionType]

// you can index into ActionsMap with K to find the specific Action
type Actions = {
    [K in ActionType]: (state: State, action: ActionsMap[K]) => State;
};
hackape
  • 18,643
  • 2
  • 29
  • 57
  • 1
    The other answer was more complete but honorable mention to you good sir. I adopted this ActionDataMap pattern as well. – CatDadCode Sep 17 '21 at 17:42
  • Sure. His answer is more educational, I learn sth too. I wasn’t aware of the `Extract` builtin type before. But in real-life scenario I think my answer is more practical in terms of readability and imposes less workload on TS type checker. – hackape Sep 18 '21 at 03:43