Infer from usage by similarity to builtin types#33263
Conversation
Basically, drop "Context" from all names, because it just indicates that it's an implementation of the State monad.
1. Everything explodes! Out of stack space! 2. Results aren't used yet. 3. But call and construct use the new getSignatureFromCalls, so I expect some baseline changes after I get the infinite recursion fixed.
Type parameter inference is special-cased, just moved from its previous place with no improvement.
It's a smeary copy of the checker's type parameter, so I feel bad about duplicating that code. Not sure what the solution is, architecturally.
| interface Usage { | ||
| isNumber?: boolean; | ||
| isString?: boolean; | ||
| isNumber: boolean | undefined; |
There was a problem hiding this comment.
cleanup: all Usage properties are now required and Usages are created with createEmptyUsage.
| else if (otherOperandType.flags & TypeFlags.StringLike) { | ||
| usage.isString = true; | ||
| } | ||
| else if (otherOperandType.flags & TypeFlags.Any) { |
| good.push(unifyAnonymousTypes(anons)); | ||
| } | ||
| return checker.getWidenedType(checker.getUnionType(good)); | ||
| return checker.getWidenedType(checker.getUnionType(good.map(checker.getBaseTypeOfLiteralType), UnionReduction.Subtype)); |
| types.push(checker.createAnonymousType(/*symbol*/ undefined!, members, callSignatures, constructSignatures, stringIndexInfo, /*numberIndexInfo*/ undefined)); // TODO: GH#18217 | ||
| } | ||
| return types; | ||
| return types; // TODO: Should cache this since I HOPE it doesn't change |
There was a problem hiding this comment.
like normal checking, caching should be fine here. I'll do that in a followup PR.
This makes inferences a lot better.
void is explicitly inferred now, never used as a fallback.
|
|
||
| switch (node.parent.kind) { | ||
| case SyntaxKind.ExpressionStatement: | ||
| addCandidateType(usage, checker.getVoidType()); |
|
|
||
| if (usage.properties && hasCalls(usage.properties.get("then" as __String))) { | ||
| const paramType = getParameterTypeFromCalls(0, usage.properties.get("then" as __String)!.calls!, /*isRestParameter*/ false)!; // TODO: GH#18217 | ||
| const types = paramType.getCallSignatures().map(sig => sig.getReturnType()); |
There was a problem hiding this comment.
this was wrong--for the expression numberPromise.then(n => n.toString()), it would infer Promise<string> not Promise<number> for numberPromise.
|
@andrewbranch @orta @weswigham I think this is ready for review now. Not sure who is most interested in this. |
| }, | ||
| getApparentType, | ||
| getUnionType, | ||
| isTypeAssignableTo: (source, target) => { |
There was a problem hiding this comment.
Even @internal, some people are going to be ecstatic that this finally found its way into our API surface and will therefore finally be callable by people using the API (even if via cast).
There was a problem hiding this comment.
Uhhhh should I be worried about exposing too much? I feeling like assignability is a State Secret or something.
There was a problem hiding this comment.
I'm really interested in the reason why it should be marked as @internal. Don't get me wrong, I'm one of those people ecstatic that this found it's way to the API, now I can ditch my custom typescript version from my library which exposed this. I'm just super curious about the reasoning behind this decision :)
There was a problem hiding this comment.
Effectively this function now has to cross conceptual boundaries in TypeScript. Going from being useful internally only inside checker to being used inside the TSServices, which meant it needed to be exposed as a public API on the checker internally within our codebase,
There was a problem hiding this comment.
Ok, but why the @internal annotation? It looks like the method is still kinda private?
There was a problem hiding this comment.
I'm not 100% convinced that arbitrary uses of this function are safe and won't corrupt compiler state. Marking it @internal allows me to use it inside Typescript where I know how it's being used. Using it outside is an any cast away, but that way it's obvious that it's not technically supported.
| } | ||
|
|
||
| calculateUsageOfNode(parent, call.returnType); | ||
| calculateUsageOfNode(parent, call.return_); |
There was a problem hiding this comment.
return_: Usage, which is the information we inferred about a signature's return type from calls.
The old name, returnType, implies that it's a type already, which it's not; that comes from inferring a usage and unifying the resulting list of types.
Unifying is a bad name too, it should probably be reconcile or aggregate.
| usage.properties = createUnderscoreEscapedMap<Usage>(); | ||
| } | ||
| const propertyUsage = usage.properties.get(name) || { }; | ||
| const propertyUsage = usage.properties.get(name) || createEmptyUsage(); |
There was a problem hiding this comment.
Yeah, this feels nicer to me 👍
| return unifyFromUsage(inferFromUsage(innerUsage)); | ||
| /** | ||
| * inference is limited to | ||
| * 1. generic types with a single parameter |
There was a problem hiding this comment.
I'm impressed we can do this 👍 - I assume it's use by the promise and array implementations
There was a problem hiding this comment.
Yep, plus I intend to expand this to other types later.
|
Tests were failing because I forgot to update a couple of baselines with the new inference code, which is unable to infer from arguments |
| } | ||
| if (propUsage.calls) { | ||
| const sigs = checker.getSignaturesOfType(source, SignatureKind.Call); | ||
| result = result && !!sigs.length && checker.isTypeAssignableTo(source, getFunctionFromCalls(propUsage.calls)); |
There was a problem hiding this comment.
Here’s an interesting test case:
function test(a) {
a.toString(2)
}Expected result: a is number
Actual result: a is { toString: (arg0: number) => void; }
It happens because every builtin’s toString call signature is assignable to the inferred structural one, and then the inference to builtins bails because of the limit of 3 union constituents. I briefly thought source and target might be backwards here—that you actually want to see if the usage is assignable to the builtin’s property—but that’s too restrictive; the parameter type of arg0: number isn’t assignable to radix?: number under strictFunctionTypes because of the optionality of the latter, and the inferred return type void is not assignable to string. (Interestingly, if you swap target and source, turn off strictFunctionTypes, and change the line to let x = a.toString(2), the inference to number occurs.)
I was able to hack up a fix for this something like:
const sigs = checker.getSignaturesOfType(source, SignatureKind.Call);
const usageType = getFunctionFromCalls(propUsage.calls);
const maxSourceParams = sigs.reduce((max, sig) => Math.max(max, sig.parameters.length), 0);
const maxUsageParams = usageType.getCallSignatures().reduce((max, sig) => Math.max(max, sig.parameters.length), 0);
result = result && !!sigs.length && maxUsageParams <= maxSourceParams && checker.isTypeAssignableTo(source, usageType);but admittedly it feels kind of bad that checking assignability doesn’t Just Work™. Maybe I’m missing something clever?
There was a problem hiding this comment.
Errr, could we get better results by checking subtype first, then assignability, like we do for overload resolution?
There was a problem hiding this comment.
I think the root problem is that we expect to infer signatures whose lengths match, and have that beat the signatures whose lengths don't, right? I think an rule in inferFromUsage would be better than trying to get subtype or assignability to do the right thing, since they have other concerns to worry about -- they are part of the Real World Checker that has to be correct, but I feel like this preference is part of the Dream World Inference we have going on here, where you choose the best candidate on what feels right a lot of the time.
This would require maintaining some additional information -- specifically a list of source signatures matched with usage-signatures, and then that matched list would be used to rank the built-in types instead of just using filter. Basically, combineNamedTypes, by analogy with combineTypes. Maybe the decision could be even deferred to combineTypes.
Let's take this PR as-is and I'll think about it for the future.
There was a problem hiding this comment.
Oh, I mean, I just suggested subtype before assignability because i thought the difference in rules would already do the right thing (tm).
I think this PR is ready for review now.
This PR:
It also infers type parameters for Array and Promise, using an algorithm that is disturbingly close to the real inference algorithm. On the whole, infer-from-usage is like a shadow checker with subtly different rules. I don't really like this, but it may be inherent in the way infer-from-usage is designed. Thoughts?
Notes:
a. In
+inference,T + anyno longer infersT = string | number.b. inference type "unification" (it is more like aggregation and selection) no longer infers literal types and now uses subtype reduction when forming unions.
c. Return type inference no longer defaults to
void; being in an expression statement infersvoidinstead.I'm not sure (2a) is correct; #30093 tracks problems with
+inference and has some examples I need to try.Follow up work:
concatdoesn't work because it has two overloads.None of these are particularly required but (2) should help a lot and not be too hard.