Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow (non-assert) type predicates to narrow by discriminant #57358

Merged
merged 5 commits into from Feb 13, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
98 changes: 51 additions & 47 deletions src/compiler/checker.ts
Expand Up @@ -26740,7 +26740,11 @@ export function createTypeChecker(host: TypeCheckerHost): TypeChecker {
function hasMatchingArgument(expression: CallExpression | NewExpression, reference: Node) {
if (expression.arguments) {
for (const argument of expression.arguments) {
if (isOrContainsMatchingReference(reference, argument) || optionalChainContainsReference(argument, reference)) {
if (
isOrContainsMatchingReference(reference, argument)
|| optionalChainContainsReference(argument, reference)
|| getCandidateDiscriminantPropertyAccess(argument, reference)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wouldn't it be better to getDiscriminantPropertyAccess(argument, type) so the argument only matches if it has an actual discriminable property? AFAIK, hasMatchingArgument is only used in two places, so specializing it a bit more for this usecase is probably fine. Technically getDiscriminantPropertyAccess can also match binding patterns and identifiers aliasing discriminable properties, too, eg,

const kind = fruid.kind;
if (isOneOf(kind, ['apple', 'banana'] as const)) {
    fruit.kind
    fruit
}

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We'll end up calling getDiscriminantPropertyAccess twice, but I'll try doing that.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You know what, at this rate, if we need to call getDiscriminantPropertyAccess to decide whether we should call narrowTypeByTypePredicate, which will then possibly call getDiscriminantPropertyAccess (and also call isMatchingReference anyways), then we might as well just always call narrowTypeByTypePredicate? I'll try that and see if it affects performance.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, getting rid of the hasMatchingArgument inside narrowTypeByCallExpression has proved disastrous: it causes a ton of errors, which look completely unrelated to narrowing. From debugging the pyright errors listed by the user tests run, I think what happens is that, inside narrowTypeByCallExpression, if we call getEffectsSignature(callExpression), we now go type checking a bunch of things, causing circularities that then cause a ton of errors.
So it looks like we do need to protect the call to getEffectsSignature(...) with a check of hasMatchingArgument(...), to avoid running into unnecessary circularities.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@weswigham I've switched to using getCandidateDiscriminantPropertyAccess for the above reasons.
Can you take a look again?

) {
return true;
}
}
Expand All @@ -26754,6 +26758,51 @@ export function createTypeChecker(host: TypeCheckerHost): TypeChecker {
return false;
}

function getCandidateDiscriminantPropertyAccess(expr: Expression, reference: Node) {
if (isBindingPattern(reference) || isFunctionExpressionOrArrowFunction(reference) || isObjectLiteralMethod(reference)) {
// When the reference is a binding pattern or function or arrow expression, we are narrowing a pesudo-reference in
// getNarrowedTypeOfSymbol. An identifier for a destructuring variable declared in the same binding pattern or
// parameter declared in the same parameter list is a candidate.
if (isIdentifier(expr)) {
const symbol = getResolvedSymbol(expr);
const declaration = symbol.valueDeclaration;
if (declaration && (isBindingElement(declaration) || isParameter(declaration)) && reference === declaration.parent && !declaration.initializer && !declaration.dotDotDotToken) {
return declaration;
}
}
}
else if (isAccessExpression(expr)) {
// An access expression is a candidate if the reference matches the left hand expression.
if (isMatchingReference(reference, expr.expression)) {
return expr;
}
}
else if (isIdentifier(expr)) {
const symbol = getResolvedSymbol(expr);
if (isConstantVariable(symbol)) {
const declaration = symbol.valueDeclaration!;
// Given 'const x = obj.kind', allow 'x' as an alias for 'obj.kind'
if (
isVariableDeclaration(declaration) && !declaration.type && declaration.initializer && isAccessExpression(declaration.initializer) &&
isMatchingReference(reference, declaration.initializer.expression)
) {
return declaration.initializer;
}
// Given 'const { kind: x } = obj', allow 'x' as an alias for 'obj.kind'
if (isBindingElement(declaration) && !declaration.initializer) {
const parent = declaration.parent.parent;
if (
isVariableDeclaration(parent) && !parent.type && parent.initializer && (isIdentifier(parent.initializer) || isAccessExpression(parent.initializer)) &&
isMatchingReference(reference, parent.initializer)
) {
return declaration;
}
}
}
}
return undefined;
}

function getFlowNodeId(flow: FlowNode): number {
if (!flow.id || flow.id < 0) {
flow.id = nextFlowId;
Expand Down Expand Up @@ -28110,57 +28159,12 @@ export function createTypeChecker(host: TypeCheckerHost): TypeChecker {
return result;
}

function getCandidateDiscriminantPropertyAccess(expr: Expression) {
if (isBindingPattern(reference) || isFunctionExpressionOrArrowFunction(reference) || isObjectLiteralMethod(reference)) {
// When the reference is a binding pattern or function or arrow expression, we are narrowing a pesudo-reference in
// getNarrowedTypeOfSymbol. An identifier for a destructuring variable declared in the same binding pattern or
// parameter declared in the same parameter list is a candidate.
if (isIdentifier(expr)) {
const symbol = getResolvedSymbol(expr);
const declaration = symbol.valueDeclaration;
if (declaration && (isBindingElement(declaration) || isParameter(declaration)) && reference === declaration.parent && !declaration.initializer && !declaration.dotDotDotToken) {
return declaration;
}
}
}
else if (isAccessExpression(expr)) {
// An access expression is a candidate if the reference matches the left hand expression.
if (isMatchingReference(reference, expr.expression)) {
return expr;
}
}
else if (isIdentifier(expr)) {
const symbol = getResolvedSymbol(expr);
if (isConstantVariable(symbol)) {
const declaration = symbol.valueDeclaration!;
// Given 'const x = obj.kind', allow 'x' as an alias for 'obj.kind'
if (
isVariableDeclaration(declaration) && !declaration.type && declaration.initializer && isAccessExpression(declaration.initializer) &&
isMatchingReference(reference, declaration.initializer.expression)
) {
return declaration.initializer;
}
// Given 'const { kind: x } = obj', allow 'x' as an alias for 'obj.kind'
if (isBindingElement(declaration) && !declaration.initializer) {
const parent = declaration.parent.parent;
if (
isVariableDeclaration(parent) && !parent.type && parent.initializer && (isIdentifier(parent.initializer) || isAccessExpression(parent.initializer)) &&
isMatchingReference(reference, parent.initializer)
) {
return declaration;
}
}
}
}
return undefined;
}

function getDiscriminantPropertyAccess(expr: Expression, computedType: Type) {
// As long as the computed type is a subset of the declared type, we use the full declared type to detect
// a discriminant property. In cases where the computed type isn't a subset, e.g because of a preceding type
// predicate narrowing, we use the actual computed type.
if (declaredType.flags & TypeFlags.Union || computedType.flags & TypeFlags.Union) {
const access = getCandidateDiscriminantPropertyAccess(expr);
const access = getCandidateDiscriminantPropertyAccess(expr, reference);
if (access) {
const name = getAccessedPropertyName(access);
if (name) {
Expand Down
30 changes: 30 additions & 0 deletions tests/baselines/reference/typePredicatesCanNarrowByDiscriminant.js
@@ -0,0 +1,30 @@
//// [tests/cases/compiler/typePredicatesCanNarrowByDiscriminant.ts] ////

//// [typePredicatesCanNarrowByDiscriminant.ts]
// #45770
declare const fruit: { kind: 'apple'} | { kind: 'banana' } | { kind: 'cherry' }

declare function isOneOf<T, U extends T>(item: T, array: readonly U[]): item is U
if (isOneOf(fruit.kind, ['apple', 'banana'] as const)) {
fruit.kind
fruit
}

declare const fruit2: { kind: 'apple'} | { kind: 'banana' } | { kind: 'cherry' }
const kind = fruit2.kind;
if (isOneOf(kind, ['apple', 'banana'] as const)) {
fruit2.kind
fruit2
}

//// [typePredicatesCanNarrowByDiscriminant.js]
"use strict";
if (isOneOf(fruit.kind, ['apple', 'banana'])) {
fruit.kind;
fruit;
}
var kind = fruit2.kind;
if (isOneOf(kind, ['apple', 'banana'])) {
fruit2.kind;
fruit2;
}
@@ -0,0 +1,63 @@
//// [tests/cases/compiler/typePredicatesCanNarrowByDiscriminant.ts] ////

=== typePredicatesCanNarrowByDiscriminant.ts ===
// #45770
declare const fruit: { kind: 'apple'} | { kind: 'banana' } | { kind: 'cherry' }
>fruit : Symbol(fruit, Decl(typePredicatesCanNarrowByDiscriminant.ts, 1, 13))
>kind : Symbol(kind, Decl(typePredicatesCanNarrowByDiscriminant.ts, 1, 22))
>kind : Symbol(kind, Decl(typePredicatesCanNarrowByDiscriminant.ts, 1, 41))
>kind : Symbol(kind, Decl(typePredicatesCanNarrowByDiscriminant.ts, 1, 62))

declare function isOneOf<T, U extends T>(item: T, array: readonly U[]): item is U
>isOneOf : Symbol(isOneOf, Decl(typePredicatesCanNarrowByDiscriminant.ts, 1, 79))
>T : Symbol(T, Decl(typePredicatesCanNarrowByDiscriminant.ts, 3, 25))
>U : Symbol(U, Decl(typePredicatesCanNarrowByDiscriminant.ts, 3, 27))
>T : Symbol(T, Decl(typePredicatesCanNarrowByDiscriminant.ts, 3, 25))
>item : Symbol(item, Decl(typePredicatesCanNarrowByDiscriminant.ts, 3, 41))
>T : Symbol(T, Decl(typePredicatesCanNarrowByDiscriminant.ts, 3, 25))
>array : Symbol(array, Decl(typePredicatesCanNarrowByDiscriminant.ts, 3, 49))
>U : Symbol(U, Decl(typePredicatesCanNarrowByDiscriminant.ts, 3, 27))
>item : Symbol(item, Decl(typePredicatesCanNarrowByDiscriminant.ts, 3, 41))
>U : Symbol(U, Decl(typePredicatesCanNarrowByDiscriminant.ts, 3, 27))

if (isOneOf(fruit.kind, ['apple', 'banana'] as const)) {
>isOneOf : Symbol(isOneOf, Decl(typePredicatesCanNarrowByDiscriminant.ts, 1, 79))
>fruit.kind : Symbol(kind, Decl(typePredicatesCanNarrowByDiscriminant.ts, 1, 22), Decl(typePredicatesCanNarrowByDiscriminant.ts, 1, 41), Decl(typePredicatesCanNarrowByDiscriminant.ts, 1, 62))
>fruit : Symbol(fruit, Decl(typePredicatesCanNarrowByDiscriminant.ts, 1, 13))
>kind : Symbol(kind, Decl(typePredicatesCanNarrowByDiscriminant.ts, 1, 22), Decl(typePredicatesCanNarrowByDiscriminant.ts, 1, 41), Decl(typePredicatesCanNarrowByDiscriminant.ts, 1, 62))
>const : Symbol(const)

fruit.kind
>fruit.kind : Symbol(kind, Decl(typePredicatesCanNarrowByDiscriminant.ts, 1, 22), Decl(typePredicatesCanNarrowByDiscriminant.ts, 1, 41))
>fruit : Symbol(fruit, Decl(typePredicatesCanNarrowByDiscriminant.ts, 1, 13))
>kind : Symbol(kind, Decl(typePredicatesCanNarrowByDiscriminant.ts, 1, 22), Decl(typePredicatesCanNarrowByDiscriminant.ts, 1, 41))

fruit
>fruit : Symbol(fruit, Decl(typePredicatesCanNarrowByDiscriminant.ts, 1, 13))
}

declare const fruit2: { kind: 'apple'} | { kind: 'banana' } | { kind: 'cherry' }
>fruit2 : Symbol(fruit2, Decl(typePredicatesCanNarrowByDiscriminant.ts, 9, 13))
>kind : Symbol(kind, Decl(typePredicatesCanNarrowByDiscriminant.ts, 9, 23))
>kind : Symbol(kind, Decl(typePredicatesCanNarrowByDiscriminant.ts, 9, 42))
>kind : Symbol(kind, Decl(typePredicatesCanNarrowByDiscriminant.ts, 9, 63))

const kind = fruit2.kind;
>kind : Symbol(kind, Decl(typePredicatesCanNarrowByDiscriminant.ts, 10, 5))
>fruit2.kind : Symbol(kind, Decl(typePredicatesCanNarrowByDiscriminant.ts, 9, 23), Decl(typePredicatesCanNarrowByDiscriminant.ts, 9, 42), Decl(typePredicatesCanNarrowByDiscriminant.ts, 9, 63))
>fruit2 : Symbol(fruit2, Decl(typePredicatesCanNarrowByDiscriminant.ts, 9, 13))
>kind : Symbol(kind, Decl(typePredicatesCanNarrowByDiscriminant.ts, 9, 23), Decl(typePredicatesCanNarrowByDiscriminant.ts, 9, 42), Decl(typePredicatesCanNarrowByDiscriminant.ts, 9, 63))

if (isOneOf(kind, ['apple', 'banana'] as const)) {
>isOneOf : Symbol(isOneOf, Decl(typePredicatesCanNarrowByDiscriminant.ts, 1, 79))
>kind : Symbol(kind, Decl(typePredicatesCanNarrowByDiscriminant.ts, 10, 5))
>const : Symbol(const)

fruit2.kind
>fruit2.kind : Symbol(kind, Decl(typePredicatesCanNarrowByDiscriminant.ts, 9, 23), Decl(typePredicatesCanNarrowByDiscriminant.ts, 9, 42))
>fruit2 : Symbol(fruit2, Decl(typePredicatesCanNarrowByDiscriminant.ts, 9, 13))
>kind : Symbol(kind, Decl(typePredicatesCanNarrowByDiscriminant.ts, 9, 23), Decl(typePredicatesCanNarrowByDiscriminant.ts, 9, 42))

fruit2
>fruit2 : Symbol(fruit2, Decl(typePredicatesCanNarrowByDiscriminant.ts, 9, 13))
}
@@ -0,0 +1,64 @@
//// [tests/cases/compiler/typePredicatesCanNarrowByDiscriminant.ts] ////

=== typePredicatesCanNarrowByDiscriminant.ts ===
// #45770
declare const fruit: { kind: 'apple'} | { kind: 'banana' } | { kind: 'cherry' }
>fruit : { kind: 'apple'; } | { kind: 'banana'; } | { kind: 'cherry'; }
>kind : "apple"
>kind : "banana"
>kind : "cherry"

declare function isOneOf<T, U extends T>(item: T, array: readonly U[]): item is U
>isOneOf : <T, U extends T>(item: T, array: readonly U[]) => item is U
>item : T
>array : readonly U[]

if (isOneOf(fruit.kind, ['apple', 'banana'] as const)) {
>isOneOf(fruit.kind, ['apple', 'banana'] as const) : boolean
>isOneOf : <T, U extends T>(item: T, array: readonly U[]) => item is U
>fruit.kind : "apple" | "banana" | "cherry"
>fruit : { kind: "apple"; } | { kind: "banana"; } | { kind: "cherry"; }
>kind : "apple" | "banana" | "cherry"
>['apple', 'banana'] as const : readonly ["apple", "banana"]
>['apple', 'banana'] : readonly ["apple", "banana"]
>'apple' : "apple"
>'banana' : "banana"

fruit.kind
>fruit.kind : "apple" | "banana"
>fruit : { kind: "apple"; } | { kind: "banana"; }
>kind : "apple" | "banana"

fruit
>fruit : { kind: "apple"; } | { kind: "banana"; }
}

declare const fruit2: { kind: 'apple'} | { kind: 'banana' } | { kind: 'cherry' }
>fruit2 : { kind: 'apple'; } | { kind: 'banana'; } | { kind: 'cherry'; }
>kind : "apple"
>kind : "banana"
>kind : "cherry"

const kind = fruit2.kind;
>kind : "apple" | "banana" | "cherry"
>fruit2.kind : "apple" | "banana" | "cherry"
>fruit2 : { kind: "apple"; } | { kind: "banana"; } | { kind: "cherry"; }
>kind : "apple" | "banana" | "cherry"

if (isOneOf(kind, ['apple', 'banana'] as const)) {
>isOneOf(kind, ['apple', 'banana'] as const) : boolean
>isOneOf : <T, U extends T>(item: T, array: readonly U[]) => item is U
>kind : "apple" | "banana" | "cherry"
>['apple', 'banana'] as const : readonly ["apple", "banana"]
>['apple', 'banana'] : readonly ["apple", "banana"]
>'apple' : "apple"
>'banana' : "banana"

fruit2.kind
>fruit2.kind : "apple" | "banana"
>fruit2 : { kind: "apple"; } | { kind: "banana"; }
>kind : "apple" | "banana"

fruit2
>fruit2 : { kind: "apple"; } | { kind: "banana"; }
}
17 changes: 17 additions & 0 deletions tests/cases/compiler/typePredicatesCanNarrowByDiscriminant.ts
@@ -0,0 +1,17 @@
// @strict: true

// #45770
declare const fruit: { kind: 'apple'} | { kind: 'banana' } | { kind: 'cherry' }

declare function isOneOf<T, U extends T>(item: T, array: readonly U[]): item is U
if (isOneOf(fruit.kind, ['apple', 'banana'] as const)) {
fruit.kind
fruit
}

declare const fruit2: { kind: 'apple'} | { kind: 'banana' } | { kind: 'cherry' }
const kind = fruit2.kind;
if (isOneOf(kind, ['apple', 'banana'] as const)) {
fruit2.kind
fruit2
}