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?
CodePudding user response:
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;
CodePudding user response:
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;
};