I’m trying to write and type-check an array of tuples that pair an action creator with its reducer, and need to validate:
- That the action’s Payload is the same as the reducer’s Payload
- That all the reducers accept the same State
The idea is to write it in a way that is library agnostic, and without having to maintain a list of valid payloads.
type A = string;
type B = number;
type State = { a: A; b: B };
type Action<Payload> = { type: string; payload: Payload };
type ActionFn<Payload> = (payload: Payload) => Action<Payload>;
const actionA: ActionFn<A> = (payload: A) => ({ type: 'A', payload });
const actionB: ActionFn<B> = (payload: B) => ({ type: 'B', payload });
const reducerA = (state: State, a: A): State => ({ ...state, a });
const reducerB = (state: State, b: B): State => ({ ...state, b });
type ActionReducerPair<State, Payload> = [
ActionFn<Payload>,
(state: State, payload: Payload) => State
];
type Reducer<State, Payload> = (state: State, payload: Payload) => State;
const reducers = [
// These are OK
[actionA, reducerA],
[actionB, reducerB],
// These should report an error
// @ts-expect-error
[actionB, reducerA],
// @ts-expect-error
[actionA, reducerB],
];
TypeScript Playground
My attempts
So it’s clear to me that I need a generic context for each entry. I’ve tried:
-
Calling a function for each entry in order
This works, however I would prefer to express the solution using only a data structure.
type Pair<State, Payload> = [ ActionFn<Payload>, (state: State, payload: Payload) => State ]; const pair = <State, Payload>( actionCreator: ActionFn<Payload>, reducerFn: (state: State, payload: Payload) => State ): Pair<State, Payload> => [actionCreator, reducerFn]; const reducerPairs2 = [ // These are OK pair(actionA, reducerA), // ✅ pair(actionB, reducerB), // ✅ // These should report an error // @ts-expect-error pair(actionB, reducerA), // ✅ // @ts-expect-error pair(actionA, reducerB), // ✅ ];
-
Enforcing the type with a type helper
This doesn’t quite work, either it passes all or it fails all.
type ValidPair<MaybeAction, MaybeReducer> = MaybeAction extends ActionFn<infer Payload> ? MaybeReducer extends Reducer<infer State, Payload> ? ActionReducerPair<State, Payload> : 'reducer does not satisfy Reducer<S, P>' : 'action does not satisfy ActionFn<P>'; const reducers = [ // These are OK [actionA, reducerA], // ✅ [actionB, reducerB], // ✅ // These should give an error // @ts-expect-error // 💥 no error reported [actionB, reducerA], // @ts-expect-error // 💥 no error reported [actionA, reducerB], ] satisfies Array<ValidPair<ActionFn<any>, Reducer<State, any>>>;
5
You can actually do that with the types you have already defined. Use your type ActionReducerPair
in a union (ActionReducerPair<State, A> | ActionReducerPair<State, B>)[]
to define all valid pairs.
type A = string;
type B = number;
type State = { a: A; b: B };
type Action<Payload> = { type: string; payload: Payload };
type ActionFn<Payload> = (payload: Payload) => Action<Payload>;
const actionA: ActionFn<A> = (payload: A) => ({ type: "A", payload });
const actionB: ActionFn<B> = (payload: B) => ({ type: "B", payload });
const reducerA = (state: State, a: A): State => ({ ...state, a });
const reducerB = (state: State, b: B): State => ({ ...state, b });
type ActionReducerPair<State, Payload> = [
ActionFn<Payload>,
(state: State, payload: Payload) => State,
];
const reducers: (ActionReducerPair<State, A> | ActionReducerPair<State, B>)[] =
[
[actionA, reducerA], // okay
[actionB, reducerB], // okay
[actionB, reducerA], // error!
[actionA, reducerB], // error!
];
TypeScript Playground
1
Conditional Types
I would solve this with a distributive conditional type on the array:
type Reducer<State, Payload> = (state: State, payload: Payload) => State;
const reducerA: Reducer<State, A> = (state: State, a: A): State => ({ ...state, a });
const reducerB: Reducer<State, B> = (state: State, b: B): State => ({ ...state, b });
type ActionReducerPair<State, Payload> = [
ActionFn<Payload>,
Reducer<State, Payload>
];
type PossiblePayloads = A | B;
type ReducerArray<State, T extends PossiblePayloads> = T extends PossiblePayloads ? ActionReducerPair<State, T>[] : never;
const reducers: ReducerArray<State, PossiblePayloads> = [
// These are OK
[actionA, reducerA],
[actionB, reducerB],
// These now give an error
[actionB, reducerA],
[actionA, reducerB],
];
Essentially, the ReducerArray
type will allow typescript to check the Payload
type to the corresponding ActionReducerPair
type.
Evidently, this solution is only valid if you can maintain the ReducerArray
type.
Edit: Changed code to use a distributive conditional type to simplify maintenance
3