Correct type inference in generic functions

May 15, 2021

For the past week or so, I have been playing with transduction (a functional programming technique to help optimize array iterations) and writing a lot of complex function types.

During that process, I have stumbled upon an issue with incorrect type inference when 2 arguments depend on the same generic type.

For instance, let’s look at this simple example:

function chain<T, R>(fn1: () => T, fn2: (arg: T) => R): R {
  return fn2(fn1());
}

chain(
  () => 5,
  //    ^
  // Type 'number' is not assignable to type 'string'.ts(2322)
  (arg: string) => parseInt(arg)
);

Both arguments fn1 and fn2 depend on the same generic T. However, since fn1 is written first and returns the value for fn2, we would expect T to be number.

But it gets inferred to string, because of the arg type of the fn2.

As a result, the error could be confusing to someone who hasn’t written this code.

The correct behavior would be to have an error for (arg: string) => parseInt(arg), saying that arg must be number, not string.

To achieve this we need the compiler to not infer the T from fn2.

One workaround would be to add another generic U that would extend T:

function chain<T, U extends T, R>(fn1: () => T, fn2: (arg: U) => R): R {
  return fn2(fn1());
  //         ^^^^^
  // Argument of type 'T' is not assignable to parameter
  // of type 'U'. 'U' could be instantiated with an
  // arbitrary type which could be unrelated to 'T'.ts(2345)
}

chain(
  () => 5,
  (arg: string) => parseInt(arg)
  // ^^^^^^^^^^^^^^^^^^^^^^^^^^^
  // Type 'number' is not assignable to type 'string'.ts(2345)
);

Although the TS now correctly complains about fn2, we end up with an error inside of the chain function. This error makes sense since U could be a more narrowed type than T. For instance:

class Car {
  /* ... */
}
class SportsCar extends Car {
  /* ... */
}

chain(
  () => new Car(),
  (arg: SportsCar) => {
    /* ... */
  }
);

In the code above we should get an error since Car isn’t assignable to SportsCar. But we won’t because U (SportsCar) extends T(Car).

Therefore, we don’t need another generic, all we need is to tell the compiler to not infer T from fn2.

This could be done by leveraging deferral of unresolved conditional types. Let’s create a NoInfer type that will do it:

type NoInfer<T> = [T][T extends any ? 0 : never];

Because the NoInfer type has a conditional T extends any, the compiler couldn’t evaluate it until it knows what the T is.

As a result, T won’t get inferred from the usage if it’s wrapped into NoInfer:

type NoInfer<T> = [T][T extends any ? 0 : never];

function chain<T, R>(fn1: () => T, fn2: (arg: NoInfer<T>) => R): R {
  return fn2(fn1());
}

chain(
  () => 5,
  (arg: string) => parseInt(arg)
  //^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  // Type 'number' is not assignable to type 'string'. ts(2345)
);

You can see that once we’ve wrapped arg of fn2 with NoInfer, the T gets resolved to number and the error is shown for the right argument (fn2).

Conclusion

That is it for today. If you liked the article make sure to share it with your friends.

Take care!


© 2023 Ayub Begimkulov All Rights Reserved