Lazily compute signature type predicates#17600
Conversation
|
For posterity, the test I was using to reproduce #17451 was: |
sandersn
left a comment
There was a problem hiding this comment.
I like the delay -- it should have always been this way to mirror the way return types are treated -- but I wish there were a more uniform way to represent 'pending' vs 'missing'. The problem is that for efficiency, the common case should be 'undefined', which for type predicates is 'missing' and for return types is 'pending'.
| } | ||
|
|
||
| function getTypePredicateOfSignature(signature: Signature): TypePredicate { | ||
| if (signature.resolvedTypePredicate === "pending") { |
There was a problem hiding this comment.
why do we need a third state when resolvedReturnType doesn't?
There was a problem hiding this comment.
oh, because all signatures have a return type (eventually), but not all signatures will have a typePredicate. So "pending" for resolvedReturnType is the equivalent of undefined for resolvedTypePredicate.
There was a problem hiding this comment.
oh, this is documented in the definition in types.ts
|
|
||
| function createSignature(declaration: SignatureDeclaration, typeParameters: TypeParameter[], thisParameter: Symbol | undefined, parameters: Symbol[], | ||
| resolvedReturnType: Type, typePredicate: TypePredicate, minArgumentCount: number, hasRestParameter: boolean, hasLiteralTypes: boolean): Signature { | ||
| resolvedReturnType: Type | undefined, resolvedTypePredicate: TypePredicate | undefined | "pending", minArgumentCount: number, hasRestParameter: boolean, hasLiteralTypes: boolean): Signature { |
There was a problem hiding this comment.
Please be aware that if we call createSignature once with a TypePredicate and then call createSignature again later with "pending" that it will result in a V8 compiling a second copy of createSignature due to the parameter being polymorphic (string vs. object).
We also end up making Signature more polymorphic. This could result in degraded performance so I would recommend you run benchmarks before and after this change to ensure this does not cause a regression.
As an alternative, I would suggest that you define something like a const unresolvedTypePredicate = <IdentifierTypePredicate>{ kind: TypePredicateKind.Identifier, parameterName: "pending", parameterIndex: 0 }; to act as your "pending" sentinel value as it will reduce polymorphism.
There was a problem hiding this comment.
Could we just use null for pending?
There was a problem hiding this comment.
We try not to use null in the compiler at all, and it would be inconsistent.
| const resolvingSignature = createSignature(undefined, undefined, undefined, emptyArray, anyType, /*typePredicate*/ undefined, 0, /*hasRestParameter*/ false, /*hasLiteralTypes*/ false); | ||
| const silentNeverSignature = createSignature(undefined, undefined, undefined, emptyArray, silentNeverType, /*typePredicate*/ undefined, 0, /*hasRestParameter*/ false, /*hasLiteralTypes*/ false); | ||
|
|
||
| const unresolvedTypePredicate: void & { __unresolvedTypePredicate: void } = (() => { |
There was a problem hiding this comment.
Why is this type so needlessly complex? Just use TypePredicate.
There was a problem hiding this comment.
I want to ensure that this is definitely not useable as a TypePredicate -- it's just one to prevent polymorphism. Otherwise it's too easy to access resolvedTypePredicate and think you're getting the right thing.
There was a problem hiding this comment.
We generally don't have that issue with other cases like noConstraintType, especially if all access to the type predicate is gated through getTypePredicateOfSignature.
While I don't think it's strictly necessary, you could add another TypePredicateKind as a discriminant, but I think using an object whose shape (and hidden class) matches other valid values for that property will reduce polymorphism.
There was a problem hiding this comment.
I would disagree that it's not an issue -- it might not be an issue if you wrote the code itself, but coming from the outside, this was a barrier to my understanding this code, and it would have been easier had I realized that resolvedReturnType was not meant to be used directly as a Type. I hadn't come across noConstraintType yet, but I'm sure it wouldn't have been easy to realize that a variable of type Type should not actually be used as a Type because it might be a special sentinel value.
There was a problem hiding this comment.
This one of many cases where we need better documentation in checker.
There was a problem hiding this comment.
I would prefer we stick to our current pattern for these cases. We can discuss these specific concerns with the broader team following the 2.5 release.
| if (apparentType !== unknownType) { | ||
| const callSignatures = getSignaturesOfType(apparentType, SignatureKind.Call); | ||
| return !!forEach(callSignatures, sig => sig.typePredicate); | ||
| return some(callSignatures, sig => signatureHasTypePredicate(sig)); |
There was a problem hiding this comment.
Can you just use signatureHasTypePredicate directly instead?
| return signature.resolvedTypePredicate !== undefined; | ||
| } | ||
|
|
||
| function getTypePredicateOfSignature(signature: Signature): TypePredicate | undefined { |
There was a problem hiding this comment.
Another approach we could consider is the one used to resolve Type Parameter constraints. Instead of using undefined to indicate no type predicate and an unresolvedTypePredicate sentinel to indicate deferred resolution, use undefined to indicate deferred resolution and a noTypePredicate sentinel. See noConstraintType as an example.
There was a problem hiding this comment.
Shouldn't undefined be used for the most common case though? Usually no type predicate exists.
There was a problem hiding this comment.
It can also mean that we don't know the answer yet. This is consistent with other "resolve"-named properties used in checker.
There was a problem hiding this comment.
I've inverted the representation, so undefined now represents a lazily-computed type predicate and noTypePredicate represents no type predicate existing.
|
Can you take a look at the build failures? |
…edicate` instead of `undefined` when in all `createSignature` calls
|
@rbuckton @ahejlsberg Any more comments? |
|
Bump -- @rbuckton @ahejlsberg Is anything blocking this? |
|
@rbuckton Good to go? |
| signature.resolvedTypePredicate = instantiateTypePredicate(getTypePredicateOfSignature(signature.target), signature.mapper); | ||
| } | ||
| return signature.resolvedTypePredicate as TypePredicate; | ||
| } |
There was a problem hiding this comment.
Write this function in the same style as getConstraintFromTypeParameter.
|
New commit contains fixes to the union half of #17757. Intersecting type predicates is marked as a TODO. |
|
@sandersn @rbuckton @ahejlsberg Anyone? |
sandersn
left a comment
There was a problem hiding this comment.
One test assertion in a comment can be checked by the compiler. Otherwise looks good.
| @@ -0,0 +1,3 @@ | |||
| declare function f<T>(predicate: (x: {}) => x is T): T; | |||
| // 'res' should be of type 'number'. | |||
There was a problem hiding this comment.
change this line to var res: number and start the next line with var res = ... and the compiler will enforce this assertion.
There was a problem hiding this comment.
I didn't want us taking any contextual type -- presumably we should be doing that on assignments if we're not already.
The baselines do enforce this assertion.
| type Or = A | B; | ||
|
|
||
| function f(o: Or, x: {}, y: {}) { | ||
| if (o.pred(x, y)) { |
There was a problem hiding this comment.
interesting.. why doesn't this match?
There was a problem hiding this comment.
oh, it's because you can't make a single type predicate that narrows two variables.
| // Lazily set by `getTypePredicateOfSignature`. | ||
| // `undefined` indicates a type predicate that has not yet been computed. | ||
| // Uses a special `noTypePredicate` sentinel value to indicate that there is no type predicate. This looks like a TypePredicate at runtime to avoid polymorphism. | ||
| // (See https://github.com/Microsoft/TypeScript/pull/17600#discussion_r132059173) |
There was a problem hiding this comment.
This link is a bet that our use of github will last as long as the typescript source. Probably a decent bet, but I personally think the explanation stands on its own as a valid justification.
(Same for the use of github bug numbers earlier in the review.)
| function cloneSignature(sig: Signature): Signature { | ||
| return createSignature(sig.declaration, sig.typeParameters, sig.thisParameter, sig.parameters, sig.resolvedReturnType, | ||
| sig.typePredicate, sig.minArgumentCount, sig.hasRestParameter, sig.hasLiteralTypes); | ||
| return createSignature(sig.declaration, sig.typeParameters, sig.thisParameter, sig.parameters, /*resolvedReturnType*/ undefined, |
There was a problem hiding this comment.
odd, I thought cloneSignature already zeroed out resolvedReturnType.
There was a problem hiding this comment.
oh, right, this is from that analysis you did which showed that all callers of cloneSignature immediately zeroed it out themselves.
| function getUnionTypePredicate(signatures: ReadonlyArray<Signature>): TypePredicate { | ||
| let first: TypePredicate | undefined; | ||
| const types: Type[] = []; | ||
| for (let i = 0; i < signatures.length; i++) { |
There was a problem hiding this comment.
If we did #19309 we could enable prefer-for-of to catch this kind of error.
| getIndexTypeOfStructuredType, | ||
| getConstraintFromTypeParameter, | ||
| getFirstIdentifier, | ||
| ), |
There was a problem hiding this comment.
Did anything change here? Otherwise, just leave it alone.
| hasRestParameter, | ||
| hasLiteralTypes) { | ||
| return createSignature(declaration, typeParameters, thisParameter, parameters, resolvedReturnType, resolvedTypePredicate || noTypePredicate, minArgumentCount, hasRestParameter, hasLiteralTypes); | ||
| }, |
| sig.thisParameter = thisParameter; | ||
| sig.resolvedReturnType = resolvedReturnType; | ||
| sig.typePredicate = typePredicate; | ||
| sig.resolvedTypePredicate = resolvedTypePredicate; |
There was a problem hiding this comment.
I would just do typePredicate || noTypePredicate here and then you won't need all the other changes.
|
I just pushed a branch with some suggested changes: https://github.com/Microsoft/TypeScript/tree/typePredicateChanges This makes the |
|
@ahejlsberg Good to go? |
|
#19676 is fixed, tested for by |
|
|
||
| isArray = ('isArray' in Array) ? | ||
| >isArray = ('isArray' in Array) ? Array.isArray : function (value) { return Object.prototype.toString.call(value) === '[object Array]'; } : (arg: any) => arg is any[] | ||
| >isArray = ('isArray' in Array) ? Array.isArray : function (value) { return Object.prototype.toString.call(value) === '[object Array]'; } : (value: any) => boolean |
There was a problem hiding this comment.
I think this change is correct because only the left hand side of the conditional is a type predicate. @ahejlsberg could you verify, then merge
There was a problem hiding this comment.
Something seems fishy here. If you reverse the order of the operands you get the old result. So apparently both types are subtypes of each other, or otherwise subtype reduction would consistently pick one of them. I'm not sure why this is the case, since (x: any) => x is any[] should be a subtype of (x: any) => boolean. You may want to investigate.
There was a problem hiding this comment.
Added a test -- the return type seems to be boolean the other way around as well.
|
I think this PR is good to go. |
Note: This includes a workaround to a type inferring issue fixed by microsoft/TypeScript#17600. When Typescript 2.7 is released, these forms can be removed.
Fixes #17451
EDIT: And #19640 and #19642 and #20186.
To summarize the problem:
During the first quickInfo, we start from a blank slate.
In
chooseOverload:excludedArgumentto[true], meaning that we exclude the sole argument.{}.excludeArgumentwill beundefined. This means we enter the body ofcheckFunctionExpressionOrObjectLiteralMethod; particularly where we callinstantiateSignature(contextualSignature, contextualMapper);.cloneTypePredicate. This uses the contextual mapper to call map the contextual type predicate type to the actual type predicate type.mapper(created bycreateInferenceContext), we cause a side-effect of fixing the inference -- so it gets stuck at{}forever.The statefulness in the original issue is because by the second quickInfo, we've already checked the function, and its return type is
(n: {}) => n is number. The explicit: n is numberannotation overrides the contextual type. SogetTypeOfFuncClassEnumModulereturns a completed full type just an empty new one, and we can infer types correctly. During the first quickInfo we didn't have a type for the function yet, so type inference problems only showed up then.We had already solved this sort of problem for contextual return types: compute them lazily so that we don't call the contextual mapper until necessary. So the solution is simply to do the same for the type predicate.