1
export const createModel = <
  T,
  R = {
    [propName: string]: <P>(prevState: T, payload: P) => T;
  }
>(model: {
  state: T;
  reducers: R;
  effects: (dispatch: {
    [K in keyof R]: (
      payload: R[K] extends (prevState: T, payload: infer P) => T ? P : never
    ) => T
  }) => Record<string, () => Promise<void>>;
}) => model;


const model = createModel<string[]>({
  state: ['foo'],
  reducers: {
    update(prevState, payload: string[]) {
      return [
        ...prevState,
        ...payload
      ]
    }
  },
  effects: dispatch => ({
    async request() {
      dispatch.update(['bar'])
      // (payload: unknown) => string[]
    }
  })
})

But I got:

TS2322: Type '<P>(prevState: string[], payload: string[]) => string[]'
is not assignable to type '<P>(prevState: string[], payload: P) => string[]'

How can I make the attribute function of dispatch get the correct payload type. Maybe it will work like Vue.defineComponent().

By the way, any articles or books can learn typescript in depth?

6
  • Can you articulate what createModel is supposed to do? Your type parameter R can literally be specified by any type, (such as boolean); you've only given it a default. When the default is used, it wants R to be an object containing functions, each of which can handle literally any payload, given the scope of the P generic. And it can have all possible keys also, so there's nothing that would prevent you from calling dispatch.florblegarbet(['bar']). I'm confused. Commented May 15, 2021 at 17:30
  • 1
    I think you might be looking for something like this, where I was essentially forced to split your function into a curried version in order to get the sort of type inference you're looking for (doing it all in one function and all in one object ends up running afoul of some of the limits in contextual and generic type inference; see microsoft/TypeScript#25826 for more info). If that meets your needs I'll write up an answer; if not, please elaborate. Commented May 15, 2021 at 17:44
  • I want a function that can infer the type of payload in 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 the dispatch object (as you see it is a mapping type inferred from the reducers object) to help me get the right type hints. Thanks in advance. @jcalz Commented May 16, 2021 at 7:56
  • And did you look at the code in this link? Does that work for your needs or not? Commented May 17, 2021 at 0:55
  • 1
    It's the only way I can get inference to work without requiring manual type annotations in multiple places; it's a design limitation of TypeScript that if your code requires inference to occur in an ordered chain of events (e.g., first infer T from state alone, then infer prevState from T alone, then infer R from T and reducers alone, then use the inferred R and T in effects) 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. Commented May 17, 2021 at 3:16

2 Answers 2

1

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

Sign up to request clarification or add additional context in comments.

Comments

0

You likely want P to extend T:

export const createModel = <
  T,
  R = {
    [propName: string]: <P extends T>(prevState: T, payload: P) => T;
    //                     ^^^^^^^^^ add this
  }
>(model: {
// ...
}) => model

This way, regardless of the previous state, you force the next state (payload) to match that type.

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.