The warning from the compiler indicates that numberReducer
is not a valid Reducer
, and this warning is justified. A Reducer
should be able to accept any Action
as its second parameter, but in the case of numberReducer
, it only accepts a specific type of action called NumberAppendAction
. This scenario can be likened to someone promoting themselves as a dog walker but only being willing to walk chihuahuas. While chihuahuas are indeed dogs, this restriction constitutes false advertising.
The problem here stems from the requirements of type safety, which dictate that function arguments must be contravariant, not covariant, in their declared types. This means that a Reducer
should be able to handle wider types, not narrower ones. TypeScript enforces this rule through the --strictFunctionTypes
flag introduced in version 2.6.
To address this issue, you could opt for a quick fix by compromising type safety through the use of any
or by disabling the --strictFunctionTypes
flag, although this approach is not recommended due to potential risks.
An alternative solution involves a more complex, type-safe strategy. Since TypeScript lacks support for existential types, directly defining a HandlerMap
with properties corresponding to reducers for diverse action types becomes challenging. Instead, one can leverage generic types, providing hints to the compiler for inferring the appropriate action types when needed.
The following code snippet showcases a possible implementation of this approach, complete with detailed comments explaining its workings:
// Implementation example with inline explanations
type Reducer<A extends Action> = (state: State, action: A) => State;
// Define HandlerMap with a union of action types for each reducer property
type HandlerMap<A extends Action> = {
[K in A['type']]: Reducer<Extract<A, { type: K }>>
}
// Verify validity of HandlerMap values for inferred action types
type VerifyHandlerMap<HM extends HandlerMap<any>> = {
[K in string & keyof HM]: (HM[K] extends Reducer<infer A> ?
K extends A['type'] ? HM[K] : Reducer<{ type: K, payload: any }> : never);
}
// Helper function to validate and return a correct HandlerMap value
const asHandlerMap = <HM extends HandlerMap<any>>(hm: HM & VerifyHandlerMap<HM>):
HandlerMap<ActionFromHandlerMap<HM>> => hm;
Furthermore, testing out the implemented solution:
const handlers = asHandlerMap({
"number/append": numberReducer
}); // No errors, handlers inferred as HandlerMap<NumberAppendAction>
By introducing a new Action
type, we can observe how mismatches are detected:
interface DogWalkAction extends Action {
type: "dog/walk",
payload: Dog;
}
declare const dogWalkReducer: (state: State, action: DogWalkAction) => State;
const handlers = asHandlerMap({
"number/append": numberReducer,
"dog/Walk": dogWalkReducer // Error flagged due to mismatched key
});
Correcting the error results in a successful operation with the enhanced type safety intact:
const handlers = asHandlerMap({
"number/append": numberReducer,
"dog/walk": dogWalkReducer
}); // Handlers now correctly represent both NumberAppendAction and DogWalkAction types
This intricate process ensures type integrity while dealing with varying action types, offering a balance between safety and complexity in TypeScript development. Choose wisely based on your priorities. Best of luck!