typescriptfp-ts

TypeScript infers types correctly in pipe but still throws a type error without explicit annotations


I’m working on a higher-order function that returns another function to be used in pipe from fp-ts. This function combines lazy arguments (computed from the input) and static arguments (provided directly). Here's the utility function:

function partialApply<FROM, TO, LAZY_ARGS extends object, STATIC_ARGS extends object>(
  func: (args: LAZY_ARGS & STATIC_ARGS) => TO,
  lazyArgs: (arg: FROM) => LAZY_ARGS,
  staticArgs: STATIC_ARGS
): (arg: FROM) => TO {
  return (arg: FROM) => func({ ...lazyArgs(arg), ...staticArgs });
}

Expected behavior when used directly

When I use this function outside of pipe, TypeScript doesn't have enough context to infer the type of FROM for the lazyArgs function, so it reasonably infers the argument num as unknown. Here’s the code:

const calculateSum = ({ a, b }: { a: number; b: number }) => ({ sum: a + b });

const result = partialApply(
  calculateSum,
  (num) => ({ a: num }), // Hovering shows `num` as `unknown`
  { b: 5 }
);

/*

Argument of type '({ a, b }: { a: number; b: number; }) => { sum: number; }' is not assignable to parameter of type '(args: object & { b: number; }) => { sum: number; }'.
  Types of parameters '__0' and 'args' are incompatible.
    Property 'a' is missing in type '{ b: number; }' but required in type '{ a: number; b: number; }'.
*/

This error makes sense because TypeScript doesn't know what FROM is unless it's used in a context where it can infer it from the surrounding code. Explicitly defining num: number fixes the error:

const result = partialApply(
  calculateSum,
  (num: number) => ({ a: num }), // Fixes the issue
  { b: 5 }
);

Problem inside pipe

When I use partialApply inside pipe, TypeScript does have enough context to infer FROM (since it’s provided by the result of the previous function in the pipeline), and when I hover over num, it correctly shows that num is a number. However, I still get the same type error unless I explicitly define num: number. Here’s the code inside pipe:

const result = pipe(
  3,
  partialApply(
    calculateSum,
    (num) => ({ a: num }), // Hovering shows `num` is a `number`
    { b: 5 }
  )
);

/*Error same as before:

Argument of type '({ a, b }: { a: number; b: number; }) => { sum: number; }' is not assignable to parameter of type '(args: object & { b: number; }) => { sum: number; }'.
  Types of parameters '__0' and 'args' are incompatible.
    Property 'a' is missing in type '{ b: number; }' but required in type '{ a: number; b: number; }'.
*/

Even though num is inferred as number, I still get an error unless I explicitly type num as number:

const result = pipe(
  3,
  partialApply(
    calculateSum,
    (num: number) => ({ a: num }), // Fixes the issue
    { b: 5 }
  )
);

My Question

Why is TypeScript throwing a type error in this case, even though it seems to correctly infer num as a number when used inside pipe, and telling what it already infers fixes the problem?

To reduce down to minimal reproducible example, remove fp-ts dependency.

The same issue happens when using this version of pipe:

const pipe = <A, B>(arg: A, func: (arg: A) => B) => {
  return func(arg)
}

Here it all is in a playground link: https://tsplay.dev/mbDybw


Solution

  • It's a longstanding limitation of TypeScript's inference algorithm that it cannot always simultaneously infer generic type arguments and contextually type callback parameters.

    TypeScript's inference isn't implemented as a "full unification" algorithm as described in microsoft/TypeScript#30134. Such an algorithm would, in principle, be guaranteed to find suitable types where possible, but it would be a big change from the current heuristic-based inference algorithm, and failures would be harder to deal with.

    Instead, the actual inference algorithm operates in a few "passes" where it first infers generics and then contextually types callback parameters; if the generic cannot be inferred until after contextual typing, then the inference generally fails. This limitation has been steadily improved, as the inference algorithm has been modified to handle some more cases, but the general issue remains and will likely always remain. There's an open issue at microsoft/TypeScript#47599 to do better, but in the description of one such improvement, microsoft/TypeScript#48538, it says that:

    inferred type information only flows from left to right between context sensitive arguments. This is a long standing limitation of our inference algorithm, and one that isn't likely to change.

    So if you have a dependency of generics and contextual types where type information needs to flow from later arguments to earlier ones, it will probably never happen.

    Note that the IntelliSense information that appears to show that the generic type argument has been properly inferred is also misleading. This is effectively another design limitation, where type display takes some shortcuts. See microsoft/TypeScript#51826. It's unfortunate, since it makes the failure even more inexplicable.


    Anyway, you can deal with it either by explicitly annotating your callback parameters so that the need for contextual typing is removed, or you can rewrite your types so that type inference can flow more easily. In your case, you could change partialApply() so that the generic type parameter corresponds directly to the type of func's argument, from which you can compute your LAZY_ARGS type parameter. Like this:

    function partialApply<F, T, A extends object, SA extends Partial<A>>(
      func: (args: A) => T,
      lazyArgs: (arg: F) => Omit<A, keyof SA>,
      staticArgs: SA,
    ): (arg: F) => T {
      return (arg: F) => func({ ...lazyArgs(arg), ...staticArgs } as any as A);
    }
    

    Yes, that makes the implementation less safe, since you need to tell TypeScript that SA & Omit<A, keyof SA> is equivalent to A (that sort of thing isn't within TypeScript's abilities to verify, see microsoft/TypeScript#28884). But now when you call partialApply(), the types are inferred as desired:

    const result = pipe(
      3,
      partialApply(
        calculateSum,
        (num) => ({ a: num }),
        { b: 5 }
      )
    );
    

    Playground link to code