Issue
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.
Solution
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 })
};
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
.
P.S. reducer
is curried, so don’t forget to pass it as reducer(actions)
to useReducer
.
Answered By – captain-yossarian
This Answer collected from stackoverflow, is licensed under cc by-sa 2.5 , cc by-sa 3.0 and cc by-sa 4.0