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

Infer type predicates for functions with multiple returns #58154

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 41 additions & 23 deletions src/compiler/checker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38373,53 +38373,71 @@ export function createTypeChecker(host: TypeCheckerHost): TypeChecker {
const functionFlags = getFunctionFlags(func);
if (functionFlags !== FunctionFlags.Normal) return undefined;

// Only attempt to infer a type predicate if there's exactly one return.
let singleReturn: Expression | undefined;
// Collect the returns; bail early if there's a non-boolean.
let returns: Expression[] = [];
if (func.body && func.body.kind !== SyntaxKind.Block) {
singleReturn = func.body; // arrow function
returns = [func.body]; // arrow function
}
else {
const bailedEarly = forEachReturnStatement(func.body as Block, returnStatement => {
if (singleReturn || !returnStatement.expression) return true;
singleReturn = returnStatement.expression;
if (!returnStatement.expression) return true;
const expr = skipParentheses(returnStatement.expression, /*excludeJSDocTypeAssertions*/ true);
returns.push(expr);
});
if (bailedEarly || !singleReturn || functionHasImplicitReturn(func)) return undefined;
if (bailedEarly || !returns.length || functionHasImplicitReturn(func)) return undefined;
}
return checkIfExpressionRefinesAnyParameter(func, singleReturn);
return checkIfExpressionsRefineAnyParameter(func, returns);
}

function checkIfExpressionRefinesAnyParameter(func: FunctionLikeDeclaration, expr: Expression): TypePredicate | undefined {
expr = skipParentheses(expr, /*excludeJSDocTypeAssertions*/ true);
const returnType = checkExpressionCached(expr);
if (!(returnType.flags & TypeFlags.Boolean)) return undefined;
function checkIfExpressionsRefineAnyParameter(func: FunctionLikeDeclaration, returns: readonly Expression[]): TypePredicate | undefined {
const allBoolean = every(returns, expr => {
const returnType = checkExpressionCached(expr);
return !!(returnType.flags & (TypeFlags.Boolean | TypeFlags.BooleanLiteral));
});
if (!allBoolean) return undefined;

return forEach(func.parameters, (param, i) => {
const initType = getTypeOfSymbol(param.symbol);
if (!initType || initType.flags & TypeFlags.Boolean || !isIdentifier(param.name) || isSymbolAssigned(param.symbol) || isRestParameter(param)) {
// Refining "x: boolean" to "x is true" or "x is false" isn't useful.
return;
}
const trueType = checkIfExpressionRefinesParameter(func, expr, param, initType);
const trueType = checkIfExpressionsRefineParameter(func, returns, param, initType);

if (trueType) {
return createTypePredicate(TypePredicateKind.Identifier, unescapeLeadingUnderscores(param.name.escapedText), i, trueType);
}
});
}

function checkIfExpressionRefinesParameter(func: FunctionLikeDeclaration, expr: Expression, param: ParameterDeclaration, initType: Type): Type | undefined {
const antecedent = (expr as Expression & { flowNode?: FlowNode; }).flowNode ||
expr.parent.kind === SyntaxKind.ReturnStatement && (expr.parent as ReturnStatement).flowNode ||
createFlowNode(FlowFlags.Start, /*node*/ undefined, /*antecedent*/ undefined);
const trueCondition = createFlowNode(FlowFlags.TrueCondition, expr, antecedent);
function checkIfExpressionsRefineParameter(func: FunctionLikeDeclaration, returns: readonly Expression[], param: ParameterDeclaration, initType: Type): Type | undefined {
const trueTypes: Type[] = [];
const anyNonNarrowing = forEach(returns, expr => {
if (expr.kind === SyntaxKind.FalseKeyword) return;
const antecedent = (expr as Expression & { flowNode?: FlowNode; }).flowNode ||
expr.parent.kind === SyntaxKind.ReturnStatement && (expr.parent as ReturnStatement).flowNode ||
createFlowNode(FlowFlags.Start, /*node*/ undefined, /*antecedent*/ undefined);
const trueCondition = expr.kind === SyntaxKind.TrueKeyword ? antecedent : createFlowNode(FlowFlags.TrueCondition, expr, antecedent);
const localTrueType = getFlowTypeOfReference(param.name, initType, initType, func, trueCondition);

const trueType = getFlowTypeOfReference(param.name, initType, initType, func, trueCondition);
if (trueType === initType) return undefined;
if (localTrueType === initType) return true;
trueTypes.push(localTrueType);
});
if (anyNonNarrowing || !trueTypes.length) return;
const trueType = getUnionType(trueTypes, UnionReduction.Subtype);

// "x is T" means that x is T if and only if it returns true. If it returns false then x is not T.
// This means that if the function is called with an argument of type trueType, there can't be anything left in the `else` branch. It must reduce to `never`.
const falseCondition = createFlowNode(FlowFlags.FalseCondition, expr, antecedent);
const falseSubtype = getFlowTypeOfReference(param.name, initType, trueType, func, falseCondition);
return falseSubtype.flags & TypeFlags.Never ? trueType : undefined;
// This means that if the function is called with an argument of type trueType, it should be narrowed to never whenever it returns false.
const reducesToNever = every(returns, expr => {
if (expr.kind === SyntaxKind.TrueKeyword) return true;
const antecedent = (expr as Expression & { flowNode?: FlowNode; }).flowNode ||
expr.parent.kind === SyntaxKind.ReturnStatement && (expr.parent as ReturnStatement).flowNode ||
createFlowNode(FlowFlags.Start, /*node*/ undefined, /*antecedent*/ undefined);
const falseCondition = expr.kind === SyntaxKind.FalseKeyword ? antecedent : createFlowNode(FlowFlags.FalseCondition, expr, antecedent);
const falseSubtype = getFlowTypeOfReference(param.name, initType, trueType, func, falseCondition);
return !!(falseSubtype.flags & TypeFlags.Never);
});
return reducesToNever ? trueType : undefined;
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -130,4 +130,4 @@ type OptionalNullableString = string | null | undefined;
declare function allowsNull(val?: OptionalNullableString): void;
declare function removeUndefinedButNotFalse(x?: boolean): false | undefined;
declare const cond: boolean;
declare function removeNothing(y?: boolean | undefined): boolean;
declare function removeNothing(y?: boolean | undefined): y is true | undefined;
Original file line number Diff line number Diff line change
Expand Up @@ -302,8 +302,8 @@ declare const cond: boolean;
> : ^^^^^^^

function removeNothing(y = cond ? true : undefined) {
>removeNothing : (y?: boolean | undefined) => boolean
> : ^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
>removeNothing : (y?: boolean | undefined) => y is true | undefined
> : ^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
>y : boolean | undefined
> : ^^^^^^^^^^^^^^^^^^^
>cond ? true : undefined : true | undefined
Expand Down
52 changes: 52 additions & 0 deletions tests/baselines/reference/inferTypePredicates.errors.txt
Original file line number Diff line number Diff line change
Expand Up @@ -325,4 +325,56 @@ inferTypePredicates.ts(205,7): error TS2741: Property 'z' is missing in type 'C1
if (foobarPred(foobar)) {
foobar.foo;
}

// Returning true can result in a predicate if the function throws earlier.
function assertReturnTrue(x: string | number | Date) {
if (x instanceof Date) {
throw new Error();
}
return true;
}

function isStringForWhichWeHaveACaseHandler(anyString: string) {
switch (anyString) {
case 'a':
case 'b':
case 'c':
return true
default:
return false
}
}

function twoReturnExpressions(x: string | number) {
switch (typeof x) {
case 'string':
return x === 'a' || x === 'b';
case 'number':
return x === 10 || x === 11;
}
}

function oneNarrowingOneNot(x: string | number) {
switch (typeof x) {
case 'string':
return x === 'a' || x === 'b';
case 'number':
return x >= 0 && x <= 10;
}
}

function ifElseIfPredicate(x: Date | string | number) {
if (x instanceof Date) {
return true;
} else if (typeof x === 'string') {
return true;
}
return false;
}

function isArrayOfStrings(x: unknown) {
if (!(x instanceof Array)) return false;

return x.every((y) => typeof y === 'string');
}

107 changes: 106 additions & 1 deletion tests/baselines/reference/inferTypePredicates.js
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,58 @@ const foobarPred = (fb: typeof foobar) => fb.type === "foo";
if (foobarPred(foobar)) {
foobar.foo;
}

// Returning true can result in a predicate if the function throws earlier.
function assertReturnTrue(x: string | number | Date) {
if (x instanceof Date) {
throw new Error();
}
return true;
}

function isStringForWhichWeHaveACaseHandler(anyString: string) {
switch (anyString) {
case 'a':
case 'b':
case 'c':
return true
default:
return false
}
}

function twoReturnExpressions(x: string | number) {
switch (typeof x) {
case 'string':
return x === 'a' || x === 'b';
case 'number':
return x === 10 || x === 11;
}
}

function oneNarrowingOneNot(x: string | number) {
switch (typeof x) {
case 'string':
return x === 'a' || x === 'b';
case 'number':
return x >= 0 && x <= 10;
}
}

function ifElseIfPredicate(x: Date | string | number) {
if (x instanceof Date) {
return true;
} else if (typeof x === 'string') {
return true;
}
return false;
}

function isArrayOfStrings(x: unknown) {
if (!(x instanceof Array)) return false;

return x.every((y) => typeof y === 'string');
}


//// [inferTypePredicates.js]
Expand Down Expand Up @@ -538,6 +590,53 @@ var foobarPred = function (fb) { return fb.type === "foo"; };
if (foobarPred(foobar)) {
foobar.foo;
}
// Returning true can result in a predicate if the function throws earlier.
function assertReturnTrue(x) {
if (x instanceof Date) {
throw new Error();
}
return true;
}
function isStringForWhichWeHaveACaseHandler(anyString) {
switch (anyString) {
case 'a':
case 'b':
case 'c':
return true;
default:
return false;
}
}
function twoReturnExpressions(x) {
switch (typeof x) {
case 'string':
return x === 'a' || x === 'b';
case 'number':
return x === 10 || x === 11;
}
}
function oneNarrowingOneNot(x) {
switch (typeof x) {
case 'string':
return x === 'a' || x === 'b';
case 'number':
return x >= 0 && x <= 10;
}
}
function ifElseIfPredicate(x) {
if (x instanceof Date) {
return true;
}
else if (typeof x === 'string') {
return true;
}
return false;
}
function isArrayOfStrings(x) {
if (!(x instanceof Array))
return false;
return x.every(function (y) { return typeof y === 'string'; });
}


//// [inferTypePredicates.d.ts]
Expand Down Expand Up @@ -583,7 +682,7 @@ declare let maybeDate: object;
declare function irrelevantIsNumber(x: string | number): boolean;
declare function irrelevantIsNumberDestructuring(x: string | number): boolean;
declare function areBothNums(x: string | number, y: string | number): boolean;
declare function doubleReturn(x: string | number): boolean;
declare function doubleReturn(x: string | number): x is string;
declare function guardsOneButNotOthers(a: string | number, b: string | number, c: string | number): b is string;
declare function dunderguard(__x: number | string): __x is string;
declare const booleanIdentity: (x: boolean) => boolean;
Expand Down Expand Up @@ -630,3 +729,9 @@ declare const foobarPred: (fb: typeof foobar) => fb is {
type: "foo";
foo: number;
};
declare function assertReturnTrue(x: string | number | Date): x is string | number;
declare function isStringForWhichWeHaveACaseHandler(anyString: string): anyString is "a" | "b" | "c";
declare function twoReturnExpressions(x: string | number): x is 10 | "a" | "b" | 11;
declare function oneNarrowingOneNot(x: string | number): boolean;
declare function ifElseIfPredicate(x: Date | string | number): x is string | Date;
declare function isArrayOfStrings(x: unknown): x is string[];
Loading