Search code examples
typescript

Type inference working incorrectly for callback function


I have simplified code snippet:

interface Store<T> {
  value: T
}

type AnyStore = Store<any>

type StoreValue<T> = T extends Store<infer V> ? V : never

function computed<
  V,
  D extends AnyStore,
  S extends Store<V>
>(
  createStore: (value: V) => S,
  depStore: D,
  compute: (value: StoreValue<D>) => V
): S {
  return createStore(compute(depStore.value))
}

interface MetaStore<T> extends Store<T> {
  meta: {}
}

function meta<T>(value: T): MetaStore<T> {
  return { value, meta: {} }
}

const depStore: Store<number> = { value: 404 }

const computedStore = computed(meta, depStore, num => `${num}`)

Playground

So I got problems with computedStore variable type

const computedStore = computed(meta, depStore, num => `${num}`)
// const computedStore: MetaStore<unknown>
// but should MetaStore<string>

If callback's argument type will be defined explicitly, then computedStore variable type is correct

const computedStore = computed(meta, depStore, (num: number) => `${num}`)
// const computedStore: MetaStore<string>

but I want to have working inference here for best DX. How to achieve desired result?


Solution

  • TypeScript's inference algorithm is heuristic and takes several passes, and whether or not it succeeds depends on, among other things, the order of the code. Inference that flows from "left to right" tends to be easier than the reverse.

    TypeScript does not perform so-called "full unification" for its type inference. Some full unification algorithms can always (with some caveats) find the appropriate set of generic and contextual type arguments if it is logically possible to do so. There is a longstanding feature request to investigate implementing full unification at microsoft/TypeScript#30134. But making such a change would be a huge undertaking and might not even make people happier on average.

    TypeScript's inference algorithm works well for a wide range of real-world code, and especially works well for code that is in the middle of being written by a human being...meaning that it helps with auto-complete and auto-suggest. Full unification algorithms tend not to be good for that. See this comment on microsoft/TypeScript#17520.

    This all means that, for now and probably forever, you can write TypeScript code where it "should" be possible for TypeScript to infer everything, but where it still fails to happen. The currently open issue for this sort of problem is microsoft/TypeScript#47599. And there are occasional improvements here, such as the update at microsoft/TypeScript#48538, which allowed left-to-right inference inside object and array literals instead of trying (and often failing) to infer them all at once. But it will almost certainly be a limitation that exists in TypeScript in some form, forever.


    If you look at the computed call signature, which is essentially

    <V, D, S>(
      createStore: (v: V) => S, 
      depStore: D, 
      compute: (v: StoreValue<D>) => V
    ) => S
    

    You can infer V either from createStore's parameter type or compute's return type. But in your code, createStore is itself a generic function whose parameter type depends on other stuff. If that's likely to happen, then the only way to be sure you can infer V is to infer it from the return type of compute. Ah, but compute depends on D. So you need to infer D before you can infer V, and only then can you infer S. But that means the inference flows from depStore to compute to createStore... but this isn't how the parameters are ordered left-to-right. So inference fails.

    If you can refactor to move the parameters around, then this particular example might start working:

    function computed<
      V,
      D extends AnyStore,
      S extends Store<V>
    >(
      depStore: D,
      compute: (value: StoreValue<D>) => V,
      createStore: (value: V) => S,
    ): S {
      return createStore(compute(depStore.value))
    }
        
    const computedStore = computed(depStore, num => `${num}`, meta)
    // const computedStore: MetaStore<string>
    

    But if you can't do that, or if doing that messes up other inferences you need to see, then you're just going to have to annotate or manually specify things you don't want to specify, such as:

    const cs1 = computed(meta, depStore, (num: number) => `${num}`)
    // const cs1: MetaStore<string>
    
    const cs2 = computed(meta<string>, depStore, num => `${num}`)
    // const cs2: MetaStore<string>
    
    const cs3 = computed<string, Store<number>, MetaStore<string>>(
      meta, depStore, num => `${num}`) // yuck
    // const cs3: MetaStore<string>
    

    No, it's not pretty, but it works.

    Playground link to code