Search code examples
typescripttype-inference

TypeScript `NoInfer` type not working as expected


I have the following code:

function inferTest<T>(factory: (setter: (setterFn: (arg: NoInfer<T>) => NoInfer<T>) => void) => T) {
    return factory(null!);
}
const inferred = inferTest((setter) => ({
    count: 0,
    increment: () => setter(state => ({ count: state.count + 1 })) // 'state' is of type 'unknown'.
}));
console.log(inferred.count); // 'inferred' is of type 'unknown'.

I want the compiler to infer the type T as

{
    count: number,
    increment: () => void,
}

However, due to the presence of the increment property in the return type of factory, the compiler is unable to infer the type T and instead assigns unknown as the type. This results in two compiler errors:

'state' is of type 'unknown'.
'inferred' is of type 'unknown'.

If you remove the increment property, then it works fine:

function inferTest<T>(factory: (setter: (setterFn: (arg: NoInfer<T>) => NoInfer<T>) => void) => T) {
    return factory(null!);
}
const inferred = inferTest(() => ({
    count: 0
}));
console.log(inferred.count);

I'm curious why making use of the setter argument in the definition of factory causes the compiler to give up on inference, especially considering the presence of the NoInfer type used on all but one of the appearances of T. Does anyone know what is happening here?


Solution

  • The NoInfer<T> utility type isn't doing anything here and isn't meant for this purpose. If you write a generic function and TypeScript infers the type argument from a place you don't want it to, you can use NoInfer to block that inference. But in the following code:

    function inferTest<T>(factory: (setter: (setterFn: (arg: T) => T) => void) => T) {
        return factory(null!);
    }
    const inferred = inferTest((setter) => ({
        count: 0,
        increment: () => setter(state => ({ count: state.count + 1 })) 
    }));
    // function inferTest<unknown>(
    //   factory: (setter: (setterFn: (arg: unknown) => unknown) => void) => unknown
    // ): unknown
    

    inference for T completely fails and the type argument falls back to the unknown type. Adding NoInfer isn't going to change or improve this.


    The reason inference fails is because your callbacks are context-dependent. You haven't annotated setter or state. TypeScript tends to defer inference of context-dependent functions until after it knows the generic type argument. But of course that type argument depends on the type of the function. TypeScript sees this as circular and can't proceed. You're hoping that the compiler can see just the count part of the return type without needing to know about the increment part. But it doesn't really work that way; TypeScript doesn't know how to do "full unification" as described in microsoft/TypeScript#30134. Instead it uses a heuristic-driven algorithm that works for a wide variety of situations and particularly works well for half-written code.

    The general problem of TypeScript being unable to infer both contextual types and generic type arguments that seem to depend on each other is described in microsoft/TypeScript#47599. There has been improvement here, such as that implemented in microsoft/TypeScript#48538. But there continue to be and will always be unsupported cases. I suspect the reason yours doesn't work as written is because you have nested contextual callbacks.

    Maybe your code will be supported as-is in some future version of TypeScript. In the meantime, the disappointing general advice here is that when inference doesn't do what you like, manually annotate and specify types. Or refactor your code so that inference can happen in a non-circular way (sometimes people can make builders so that instead of a single self-referencing object, they can incrementally build it up). For your code it's probably best to just define a type for { count: number, increment: () => void } and manually specify T as that type:

    interface MyType {
      count: number;
      increment(): void;
    }
    inferTest<MyType>((setter) => ({
      count: 0,
      increment: () => setter(state => (
        {
          count: state.count + 1,
          increment() { } // <-- you forgot this
        }))
    }));
    

    In any case, NoInfer<T> won't do anything useful for this situation. All it does is block unwanted inference, which could possibly allow some other inference to happen. But if there's no inference, then there's nothing to block. There's no PleaseInfer<T> type (and it's not clear how such a thing would be implemented in a way that would actually deal with microsoft/TypeScript#47599, as opposed to just failing there also).

    Playground link to code