Conceptually, your createModel() should have the following type:
export const createModel = <T, P extends object>(
model:
{
state: T,
reducers: {
[K in keyof P]: (prevState: T, payload: P[K]) => T
},
effects: (dispatch: {
[K in keyof P]: (
payload: P[K]
) => T
}) => Record<string, () => Promise<void>>
}
) => model;
Here, T corresponds to the type of state, while P corresponds to the mapping from keys in reducers and effects to payload types. This is very straightforward and clean, but unfortunately the compiler really can't infer P from call sites:
// T inferred as string[],
// P inferred as object
const badModel = createModel({
state: ["foo"], reducers: {
update(prevState, payload: string[]) {
return [
...prevState,
...payload
]
}
}, effects: dispatch => ({
async request() {
dispatch.update(['bar']) // error!
// ----> ~~~~~~ <-- update not present on object
//
}
})
});
Here the type T is correctly inferred as string[], but the compiler is unable to use reducers or effects to infer the keys of P, and the compiler ends up just falling back to the object constraint. And so you get errors.
If you don't mind manually specifying type parameters when you call createModel(), then things will work out:
const model = createModel<string[], { update: string[] }>({
state: ["foo"], reducers: {
update(prevState, payload) {
return [
...prevState,
...payload
]
}
}, effects: dispatch => ({
async request() {
dispatch.update(['bar']) // okay
//
}
})
});
So that's one way to proceed.
But if you don't want to write out redundant information (e.g., a third instance of update and unnecessarily mentioning string[] for T), then you need to care about TypeScript's type parameter and contextual type inference algorithm, and its limitations.
Roughly, the compiler takes a certain small number of inference "passes" where it tries to use information to determine candidates for generic type parameter types (like P), or for the type of an unannotated callback parameter (like prevState). It plugs in those candidates and checks again to try to infer more things. But it's very easy for the compiler to run out of inference passes before inferring everything you care about, and it gives up and falls back to some general type like unknown or whatever type a parameter is constrained to. See microsoft/TypeScript#25826, microsoft/TypeScript#29791, and microsoft/TypeScript#38872 for examples.
For now, this is just a limitation of the language. There is a suggesstion at microsoft/TypeScript#30134 for the compiler to use a more complete unification algorithm for type inference, but who knows if anything like that will ever happen. It's best to just work with the system we have.
The most straightforward way around this problem is to split the inference job into manageable chunks, where each chunk needs just one or two things inferred, and then refactor so that the compiler more or less has to infer in that order. In this case, we can take your single generic function and split it into a curried function where each subsequent call adds in a bit more to the model:
export const createModel = <T,>(state: T) =>
<R extends Record<keyof R, (prevState: T, payload: any) => T>>(reducers: R) =>
(effects: (dispatch: {
[K in keyof R]: (
payload: R[K] extends (prevState: any, payload: infer P) => any ? P : never
) => T
}) => Record<string, () => Promise<void>>) => ({ state, reducers, effects });
Note that it's not always possible to infer a type like P from a value of a mapped type that depends on P but is not P itself. It is much easier to infer a type like R from a value of type R itself. This means we have to calculate our payload types from our reducer types, using conditional type inference similar to your original code. It's uglier, but it helps with inference.
Anyway, the order of operations above is: First we ask for state and infer T from it. Then we ask for reducers and infer R from it, which has already been constrained to an object type that depends on the already-inferred T. This should also allow the compiler to contextually infer the type of prevState. And then we ask for effects, whose type should already be completely known since T and R fix it completely.
Let's try it:
const model = createModel(["foo"])({
update(prevState, payload: string[]) {
return [
...prevState,
...payload
]
}
})(dispatch => ({
async request() {
dispatch.update(['bar'])
}
}));
/* const model: {
state: string[];
reducers: {
update(prevState: string[], payload: string[]): string[];
};
effects: (dispatch: {
update: (payload: string[]) => string[];
}) => Record<string, () => Promise<void>>;
} */
Looks good. The compiler infers T as string[] and R as the correct type for reducers, and then effects is also properly typed.
It's up to you whether manually specifying types is more or less annoying than writing complexly-typed curried functions.
Playground link to code
createModelis supposed to do? Your type parameterRcan literally be specified by any type, (such asboolean); you've only given it a default. When the default is used, it wantsRto be an object containing functions, each of which can handle literally anypayload, given the scope of thePgeneric. And it can have all possible keys also, so there's nothing that would prevent you from callingdispatch.florblegarbet(['bar']). I'm confused.payloadin the reducers function (actually the type of payload may be different from the state). When writing a side effect function, I want to get the correct type of thedispatchobject (as you see it is a mapping type inferred from the reducers object) to help me get the right type hints. Thanks in advance. @jcalzTfromstatealone, then inferprevStatefromTalone, then inferRfromTandreducersalone, then use the inferredRandTineffects) the compiler might not be able to do all of it at once in a single function call. By currying the function you can control the order. I will probably write up an answer to this effect.