Not planned
Description
Suggestion
function test<T>(fn: (prev: T) => T) { }
test((prev) => ({ a: 1 })); // T is inferred as "unknown"
🔍 Search Terms
Maybe an issue exists, but I wasn't sure what to search for. "Infer generic function type from return value" didn't really help.
✅ Viability Checklist
My suggestion meets these guidelines:
- This wouldn't be a breaking change in existing TypeScript/JavaScript code. not sureThis wouldn't change the runtime behavior of existing JavaScript codeThis could be implemented without emitting different JS based on the types of the expressionsThis isn't a runtime feature (e.g. library functionality, non-ECMAScript syntax with JavaScript output, new syntax sugar for JS, etc.)This feature would agree with the rest of TypeScript's Design Goals.
⭐ Suggestion
T
should be inferred as the type of the returned object, {a: number}
📃 Motivating Example
💻 Use Cases
make life easier
Metadata
Metadata
Assignees
Labels
Type
Projects
Milestone
Relationships
Development
No branches or pull requests
Activity
Retsam commentedon Jun 21, 2022
It's not that TS doesn't infer the generics, in general, but just that it's inferring it as
unknown
in this specific case.It looks like the issue here is the unannotated
prev
, since that'sunknown
,T
ends up asunknown
.In this very simple case (where
prev
is unused), it's fairly easy to see what the right behavior would be, but I suspect that evaluating the return type of a function and using that to infer the type of its arguments (which usually affect the return type) is not a simple thing to do.RyanCavanaugh commentedon Jun 21, 2022
Two cases to consider here
The first case is where the function expression uses the parameter but the return type of the function provably doesn't depend on the parameter type. In practice, it's very rare for these kinds of function expressions to exist - they're by definition impure, and given control flow effects it's very difficult to even construct such a function.
The second case is where the function expression doesn't use the parameter, as is the case here. They can be removed WLOG, and after doing so the parameter is inferred successfully as expected.
So on balance there's not really much gain to be had here - the inference is still sound, and detecting if a used parameter has no effect on the return type of a function is a very difficult calculation to always get right
fatcerberus commentedon Jun 21, 2022
I had to google this acronym. First time I've ever encountered it.
To me it feels like TS should be treating
(prev) => 42
as equivalent to() => 42
for the purpose of generic inference--that is to say,prev
is not a valid inference site forT
since it has no type annotation and can only later be typed through contextual typing--which requiresT
to be known first. If it weren't for the generic,(prev) => 42
would be an implicitany
error. Effectively, the presence of the generic creates this weird pseudo-circular situation whereT
is indirectly inferred from its own constraint via contextual typing, which feels... wrong.RyanCavanaugh commentedon Jun 21, 2022
Yeah, I'll reopen #47599 since it's not fully covered
RyanCavanaugh commentedon Jun 21, 2022
That said, I'm not really sure what the endgoal is. Any "improvement" we make here is just going to sow a bunch of "TypeScript is inconsistent, therefore has bug" reports because people will wonder why
works but not
The current behavior is at least very explainable and easy to reason about; moving the needle into the grey zone just raises more questions than it does solve problems.
fatcerberus commentedon Jun 21, 2022
Yeah, I understand. What bothers me is mostly theoretical - the fact that the current behavior basically amounts to:
T
?prev
is aT
, we can inferT
from the callback the caller passed in...prev
has no type annotation!prev
is aT
, but we don't know whatT
is yet...prev: unknown
(i.e. the constraint ofT
).T
isunknown
.It feels like a bug, even if the "correct" behavior isn't really qualitatively "better" in practice.
fatcerberus commentedon Jun 21, 2022
To be clear, my problem isn’t the
prev: unknown
part (that part makes sense), but the fact thatT
ultimately has its own constraint as its inference site. I guess there’s no mechanism to fix the latter without compromising the former, though.RyanCavanaugh commentedon Jun 21, 2022
I believe what actually happens is that we collect candidates for
T
, find out there are none, so default it to its constraint, which isunknown
, and then process the call as if you had writtentest<unknown>(
.It's weird since if you had written
test<unknown>(
, that definitely shouldn't be an implicitany
error. Maybe we need to make a markerunknown
to use in zero-candidate inference that isn't allowed to contextually type a parameter - worth experimenting with, probably.fatcerberus commentedon Jun 21, 2022
I think a big part of what makes this case so tricky is that
T
is found in both covariant and contravariant positions. If you infer only from the covariant (return) position, you’re likely to end up with a type that’s too narrow, as you show in the examples above. I think the only change that would make sense is for this to become an error, a la implicit-any, as in the absence of a type annotation on the callback parameter, the contravariant position can’t be inferred from without always "inferring" the constraint. I acknowledge turning this into an error is a potentially disruptive breaking change, though.RyanCavanaugh commentedon Jun 21, 2022
@weswigham pointed out this example
It's not clear how we'd turn this into an error -- somehow it's (speculatively) a "
Box<implicit any>
" which isn't really a thing - implicit any arises when a binding should have a contextual type but doesn't, but that's not what's happening here. And zero-candidate inference is not a manifest error either:So there seems to be some difficulty in establishing what rule exactly would turn the OP example into an error without either breaking something that shouldn't be broken (benign zero-candidate inference), failing to break something equally suspect (
Box<
), or both.blaumeise20 commentedon Jun 22, 2022
Does TypeScript have a Hindley-Milner type system? Because I'm pretty sure it would work with that kind of type inference.
TypeScript would see that the return type and first parameter have to be the same, so it could check for return type and see "oh it's an object literal with type
{ a: number }
". And because it knows thatprev
must be of the same type, it can say thatprev
is of type{ a: number }
too.It seems like it doesn't work like that now, right?
fatcerberus commentedon Jun 22, 2022
No, TS's type system is not H-M. Type inference is done locally. See #30134.
Your example of inferring
prev
based on the return type was discussed above:would infer
T = 0 | 1
under your proposed behavior, which isn't ideal (number
would be more useful). In general this behavior would tend to infer types that are too narrow. It's a tricky case because you generally want to infer wider types for a parameter but narrower types for a return type, but here they're required to be the same type.trusktr commentedon Aug 26, 2022
Interesting points above.
I can describe a real-world use case. Suppose we have a
memo
function that is reactive, and re-runs an expression any time dependencies used inside of the function body change. Thememo
tool can be called in two ways. The first way:In this first case,
prev
arguments can only ever bestring | undefined
, where on the very first run of the reactive expression the initialprev
value isundefined
. It can never be anything other thanstring | undefined
. Note though that the return value is neverstring | undefined
but juststring
.Any time that
fname.set(string)
orlname.set(string)
are called, it causes the function passed tomemo
to re-execute to evaluate the newfullname
and trigger other reactive expressions elsewhere that depend onfullname
.The second way to use the
memo
API is like this:In this case,
prev
arguments will only ever bestring
s. They will never be anything else, because the initial value use forprev
will be"Godzilla Kong"
, and the return value is always astring
.This works totally fine in plain JavaScript, but the main issue is that in TypeScript, it currently requires too much superfluous type annotation.
12 remaining items