The algorithm used by TypeScript for type inference is based on heuristics and goes through multiple passes; its success depends on various factors, including the code's order. In general, inference that proceeds from left to right tends to be more straightforward than the reverse.
Unlike some other full unification algorithms, TypeScript does not employ "full unification" in its type inference process. While certain formulas can always determine the correct set of generic and contextual type arguments under specific conditions, TypeScript doesn't adopt this approach. There have been longstanding requests to consider implementing full unification (microsoft/TypeScript#30134), but such a change would be complex and may not necessarily result in improved overall satisfaction.
TypeScript's inference mechanism functions effectively for a diverse range of practical code, especially during code composition by human developers - assisting with auto-completion and suggestions. On the contrary, comprehensive unification algorithms tend to struggle in these scenarios. Refer to this comment on microsoft/TypeScript#17520 for more information.
As a consequence, there will likely persist instances where TypeScript falls short of inferring everything despite one's expectations. The ongoing issue related to this problem is detailed in microsoft/TypeScript#47599. Occasionally, enhancements are introduced, like the recent update in microsoft/TypeScript#48538 enabling left-to-right inference within object and array literals instead of attempting to infer them all at once. However, it's probable that this limitation will endure in some manifestation within TypeScript indefinitely.
For instance, when examining the computed
call signature:
<V, D, S>(
createStore: (v: V) => S,
depStore: D,
compute: (v: StoreValue<D>) => V
) => S
You can deduce the value of V
either from the parameter type of createStore
or the return type of compute
. However, in cases where createStore
itself is a generic function dependent on other factors, inferring V
solely from the output of compute
becomes crucial. Yet, as compute
relies on D
, inferring D</code before <code>V
- and consequently S
- is necessary. This implies an inference path from depStore
to compute
to createStore
, diverging from the parameters' natural left-to-right order, resulting in inference failure.
If reordering the parameters is a viable option, the said example might yield desired outcomes:
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>
However, if rearranging poses challenges or disrupts other essential inferences, manual annotations or specifications may become necessary despite being less appealing:
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}`) // not ideal
// const cs3: MetaStore<string>
Although not elegant, the workaround gets the job done.
Link to Code Playground for Testing