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!