Reputation: 935
Assume this code:
// base class:
class Pin<Output, Input=Output> {
to<Out>(pin: Pin<Out, Output>): Pin<Out, Output> {
return pin;
}
from<In>(pin: Pin<Input, In>): Pin<Input, In> {
return pin;
}
}
//some type aliasing for convenience:
type SyncFunc<I, O> = (i: I) => O;
type Resolve<T> = SyncFunc<T, void>;
type AsyncFunc<I, O> = (i: I, cb: Resolve<O>) => void;
type Func<I, O> = SyncFunc<I, O> | AsyncFunc<I, O>;
function src<Type>(): Pin<Type> { return new Pin<Type>(); }
function map<I, O>(m: SyncFunc<I, O>): Pin<O, I>;
function map<I, O>(m: AsyncFunc<I, O>): Pin<O, I>;
function map<I, O>(m: SyncFunc<I, O> | AsyncFunc<I, O>): Pin<O, I> {
return new Pin<O, I>();
}
With this setup, the following code will have errors:
src<number>().to(map((i, c: Resolve<number>) => c(i * 2))).to(map(x => x + 1));
As type of i
is not properly inferred.
I can change the overloaded signatures arrangement like this:
function map<I, O>(m: AsyncFunc<I, O>): Pin<O, I>;
function map<I, O>(m: SyncFunc<I, O>): Pin<O, I>;
function map<I, O>(m: SyncFunc<I, O> | AsyncFunc<I, O>): Pin<O, I> {
return new Pin<O, I>();
}
Which fixes the issue with previous example, but causes an issue with this one:
src<number>().to(map(i => i * 2)).to(map(x => x + 1));
Because the function passed to map()
is now assumed to be of type AsyncFunc
, the return type is not resolved, and so x
is assumed to be of type unknown
, which results in another error.
Wanted to open an issue on Typescript's GitHub, but thought of first asking here to ensure I'm not missing anything here. Is this expected behavior? i.e is it a bug with Typescript's type inference, or a feature that it currently lacks, or am I missing something?
Upvotes: 1
Views: 165
Reputation: 328262
The main issue you're running into is the possibly-surprising type compatibility of a value like x => x + 1
and i => i * 2
with both SyncFunc<number, number>
and AsyncFunc<number, number>
:
const sf: SyncFunc<number, number> = x => x + 1; // okay
const af: AsyncFunc<number, number> = x => x + 1; // also okay... what?!
This is working as intended but confuses enough people that it's part of the TypeScript FAQ. I'll adapt the info there to this question:
Q: How could x => x + 1
be assignable to AsyncFunc
when the former accepts a single parameter while the latter requires two parameters? How could a function of one parameter be assignable to a function of two parameters?
A: x => x + 1
is a valid assignment for AsyncFunc
because it can safely ignore extra parameters. At runtime, (x => x + 1)(10, "randomExtraParam")
works. Making this an error at compile time would end up invalidating a lot of conventional uses of methods like Array.forEach()
or Array.map()
whose implementation passes multiple parameters to the callback but are often used with callbacks of a single parameter. You can read the FAQ entry linked above for why they think this is the best behavior here.
Q: How could x => x + 1
be assignable to AsyncFunc
when the former returns a number
(assuming x
is inferred as number
) and the latter returns void
? How could a function returning a value be assignable to one that doesn't?
A: The void
return type means that the caller cannot expect a return value, but it doesn't mean that there definitely won't be one. It just means that the caller should ignore any value that's returned. If you're ignoring any return value I give you, then it shouldn't matter if I return 1
instead of return undefined
. Making this an error would end up invalidating a lot of conventional uses of callback-accepting-functions that don't consult the callback's return value, like Array.forEach()
. Some callbacks like a => arr.push(a)
have both side-effects and return values, and forbidding return values would make it harder to use those in conventional ways.
In light of that, the behavior is understandable; the compiler cannot reliably distinguish between AsyncFunc<number, number>
and SyncFunc<number, number>
. To make this happen, you should probably alter the types to be incompatible. One way to do this is to make the return type of AsyncFunc
something other than void
, such as undefined
:
type Resolve<T> = SyncFunc<T, undefined>;
type AsyncFunc<I, O> = (i: I, cb: Resolve<O>) => undefined;
They are similar, in that a function without a return someValue
statement will end up returning undefined
, but the compiler will now be unhappy if you return 1
instead of undefined
in an AsyncFunc
.
This still doesn't solve the problem of distinguishing functions by number of parameters. If you just write the call signature of map()
as <I, O>(m: Func<I, O>): Pin<O, I>
, the compiler technically has enough information to see that x => x + 1
must be a SyncFunc
, but there's apparently too much for it to infer at once: it needs to infer both the type parameter I
and the type of x
, but those depend on each other and it gives up. Sometimes you can get it to work, but it's not always worth the effort. See microsoft/TypeScript#30134 for an issue and discussion surrounding the limitations of the current type inference algorithm and possibilities to improve it.
For now, though, you've already got a workaround that behaves the way you want: use overloads to guide the compiler into first making a serious effort at interpreting x => x + 1
as an AsyncFunc
, have it fail (because of the incompatible return type) and then try SyncFunc
and succeed. This has the same problem as before about I
and x
, but the type SyncFunc
is apparently simple enough for it to work (compared to Func
).
That means the following works for you:
function map<I, O>(m: AsyncFunc<I, O>): Pin<O, I>;
function map<I, O>(m: SyncFunc<I, O>): Pin<O, I>;
function map<I, O>(m: Func<I, O>): Pin<O, I> {
return new Pin<O, I>();
}
src<number>().to(map((i, c: Resolve<number>) => c(i * 2))).to(map(x => x + 1));
src<number>().to(map(i => i * 2)).to(map(x => x + 1));
Okay, hope that helps; good luck!
Upvotes: 2