From 56e472a225ebe8888c2e4d802a29d57705d69176 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sun, 18 Oct 2026 11:04:03 -0500 Subject: [PATCH] Return undefined from generic functions that read a missing element A generic function, arrow or method whose body returns an array element or an index-signature entry now returns undefined for a missing one, as in Node, instead of throwing. Only declarations whose bodies return such a read change their instance result type. Fixes #964 Co-authored-by: Quim T. <26749475+espetro@users.noreply.github.com> --- .../src/frontend/lowering/function-returns.ts | 7 ++ .../frontend/lowering/generic-functions.ts | 9 +- .../compiler/src/frontend/lowering/lowerer.ts | 92 +++++++++++++++++++ .../5954-generic-optional-returns.ts.json | 6 ++ tests/corpus/5954-generic-optional-returns.ts | 51 ++++++++++ 5 files changed, 163 insertions(+), 2 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5954-generic-optional-returns.ts.json create mode 100644 tests/corpus/5954-generic-optional-returns.ts diff --git a/packages/compiler/src/frontend/lowering/function-returns.ts b/packages/compiler/src/frontend/lowering/function-returns.ts index d3c5eb1ba3..4f519691f2 100644 --- a/packages/compiler/src/frontend/lowering/function-returns.ts +++ b/packages/compiler/src/frontend/lowering/function-returns.ts @@ -157,6 +157,13 @@ export function declaredReturnType( } if (mappedReturn && isJsSourceFile(decl.getSourceFile()) && functionCanReturnUndefined(decl)) mappedReturn = lowerer.withUndefinedArmOf(mappedReturn) ?? mappedReturn; + // A generic-class method body that can return an indexed read that + // misses returns undefined there, whatever the declared result says. + if (mappedReturn && lowerer.runtimeOptionalGenericReturns.has(decl)) + mappedReturn = + mappedReturn.kind === "promise" + ? { kind: "promise", inner: lowerer.runtimeOptionalType(mappedReturn.inner) } + : lowerer.runtimeOptionalType(mappedReturn); if (isJsSourceFile(decl.getSourceFile()) && mappedReturn?.kind === "void") { let returnsValue = false; const scan = (node: ts.Node): void => { diff --git a/packages/compiler/src/frontend/lowering/generic-functions.ts b/packages/compiler/src/frontend/lowering/generic-functions.ts index bd785a2566..90f83ec2c2 100644 --- a/packages/compiler/src/frontend/lowering/generic-functions.ts +++ b/packages/compiler/src/frontend/lowering/generic-functions.ts @@ -292,11 +292,16 @@ export function genericCallInstance( (_param, i) => expr.arguments[i] ?? expr, ); const retTs = lowerer.checker.getReturnTypeOfSignature(rsig); - const returnType = lowerer.mapTypeOf(retTs); - if (!returnType) { + const mappedReturn = lowerer.mapTypeOf(retTs); + if (!mappedReturn) { fenceGenericSignatureResult(lowerer, expr, retTs); lowerer.badType(expr, retTs); } + // A body that can return an indexed read that misses returns undefined + // there, as in JavaScript, whatever the declared result says. + const returnType = lowerer.runtimeOptionalGenericReturns.has(info.decl) + ? lowerer.runtimeOptionalType(mappedReturn) + : mappedReturn; // keyof-constrained type parameters (`K extends keyof T`): the bound // LITERAL is semantic — the instance body reads the named field — so diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index b77768c283..40cf40de9c 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -1880,6 +1880,10 @@ export class Lowerer { * declared optional binding to that read's bare type, so presence tests * on these bindings consult the stored tag instead. */ readonly runtimeOptionalAssignedSymbols = new Set(); + /** Generic function declarations whose body can return an indexed read + * that misses. + * Each instance's result admits undefined (generic-functions.ts). */ + readonly runtimeOptionalGenericReturns = new Set(); /** Concise callback/lambda returns promoted by the HOF callback prepass; * arrows have no declaration symbol to key in fnSigsBySymbol. */ readonly runtimeOptionalFunctionReturns = new Map(); @@ -4289,6 +4293,79 @@ export class Lowerer { const read = this.mapTypeOf(this.typeOf(e)); return !(read?.kind === "union" && this.armTag(read.unionId, UNDEFINED_T) >= 0); }; + /** An optional value in a generic body, where element types are type + * parameters with no IR mapping yet: an element read of an array or a + * read of a string index signature, judged on checker types. */ + const genericClassMethods: ts.MethodDeclaration[] = []; + for (const sf of sourceFiles) { + if (isJsSourceFile(sf) || sf.isDeclarationFile) continue; + ts.walkPreorder(sf, (node) => { + if (!ts.isClassDeclaration(node)) return undefined; + const genericClass = (node.typeParameters?.length ?? 0) > 0; + for (const member of node.members) + if ( + ts.isMethodDeclaration(member) && + member.body && + (genericClass || (member.typeParameters?.length ?? 0) > 0) + ) + genericClassMethods.push(member); + return undefined; + }); + } + const genericMayBeOptional = (node: ts.Expression): boolean => { + if (explicitlyNonNull(node)) return false; + if (mayBeOptional(node)) return true; + const e = peel(node); + if (ts.isConditionalExpression(e)) + return genericMayBeOptional(e.whenTrue) || genericMayBeOptional(e.whenFalse); + if (ts.isBinaryExpression(e)) { + const op = e.operatorToken.kind; + if (op === ts.SyntaxKind.BarBarToken || op === ts.SyntaxKind.QuestionQuestionToken) + return genericMayBeOptional(e.right); + if (op === ts.SyntaxKind.AmpersandAmpersandToken) + return genericMayBeOptional(e.left) || genericMayBeOptional(e.right); + return false; + } + if (ts.isIdentifier(e)) { + // A binding initialized from such a read and never reassigned. + const symbol = symbolOf(e); + const decl = symbol ? this.checker.valueDeclarationOf(symbol) : undefined; + return ( + symbol !== null && + !reassigned.has(symbol) && + decl !== undefined && + ts.isVariableDeclaration(decl) && + decl.initializer !== undefined && + ts.isIdentifier(decl.name) && + !provenPresent(e, symbol) && + genericIndexedRead(decl.initializer) + ); + } + return genericIndexedRead(e); + }; + const genericIndexedRead = (node: ts.Expression): boolean => { + const e = peel(node); + if (!ts.isElementAccessExpression(e) || explicitlyNonNull(node)) return false; + const read = typeOrNull(e); + if (!read || (read.flags & (ts.TypeFlags.Undefined | ts.TypeFlags.Any)) !== 0) return false; + if ( + read.isUnionType() && + ts.constituentTypes(read).some((t) => (t.flags & ts.TypeFlags.Undefined) !== 0) + ) + return false; + const receiver = typeOrNull(e.expression); + if (!receiver || this.checker.isTupleType(receiver)) return false; + if (this.checker.isArrayType(receiver)) return true; + const key = e.argumentExpression; + if ( + (ts.isStringLiteral(key) || ts.isNumericLiteral(key)) && + this.checker.getPropertyOfType(receiver, key.text) !== undefined + ) + return false; + return this.checker + .getIndexInfosOfType(receiver) + .some((info) => (info.keyType.flags & ts.TypeFlags.StringLike) !== 0); + }; const isDynamicObjectEntryRead = (node: ts.Expression): boolean => { const read = peel(node); if (!ts.isElementAccessExpression(read) || !ts.isIdentifier(read.expression)) return false; @@ -5815,6 +5892,21 @@ export class Lowerer { for (const sf of sourceFiles) if (scanFile(sf)) changed = true; for (const [symbol, decl] of functionDeclBySymbol) if (scanReturns(symbol, decl)) changed = true; + const noteGenericReturn = (symbol: ts.Symbol, decl: ts.FunctionLikeDeclaration): void => { + if (optionalReturns.has(symbol) || isJsSourceFile(decl.getSourceFile())) return; + if (!returnsOf(decl).some(genericMayBeOptional)) return; + optionalReturns.add(symbol); + this.runtimeOptionalGenericReturns.add(decl); + provenance?.note("return", decl.name ?? decl, decl); + changed = true; + }; + for (const [symbol, info] of this.genericFnsBySymbol) noteGenericReturn(symbol, info.decl); + // Generic methods and methods of a generic class collect with their + // class, after this analysis; their result admits undefined there. + for (const method of genericClassMethods) { + const symbol = method.name ? symbolOf(method.name) : null; + if (symbol) noteGenericReturn(symbol, method); + } for (const fn of tsLambdas) { // An annotated return is the author's contract, as for parameters, // except that a var read before its first store is undefined diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5954-generic-optional-returns.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5954-generic-optional-returns.ts.json new file mode 100644 index 0000000000..a57275c125 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5954-generic-optional-returns.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5954-generic-optional-returns.ts" + ], + "diags": [] +} diff --git a/tests/corpus/5954-generic-optional-returns.ts b/tests/corpus/5954-generic-optional-returns.ts new file mode 100644 index 0000000000..9aaec5889a --- /dev/null +++ b/tests/corpus/5954-generic-optional-returns.ts @@ -0,0 +1,51 @@ +// A generic function, method or arrow that returns an element read or an +// index-signature read answers undefined for a missing element or key, as +// in Node, for every instantiation. +export {}; + +function at(items: T[], index: number): T { + return items[index]; +} +function lookup(table: Record, key: string): T { + return table[key]; +} +function viaBinding(items: T[], index: number): T { + const item = items[index]; + return item; +} +function either(items: T[], index: number, fallback: T): T { + return index >= 0 ? items[index] : fallback; +} +const first = (items: T[]): T => items[0]; + +console.log(at([1, 2], 1), at([1, 2], 5), at(["a"], 0), at(["a"], 3)); +console.log(lookup({ a: 1 }, "a"), lookup({ a: 1 }, "b"), lookup({ s: "x" }, "t")); +console.log(viaBinding([true], 0), viaBinding([true], 2)); +console.log(either([1], 4, 9), either([1], -1, 9)); +console.log(first([7]), first([]), first(["only"])); + +const missing = at([10, 20], 7); +console.log(missing === undefined, typeof missing); +const label = lookup({ a: "A" }, "z"); +console.log(label ?? "none", label === undefined); + +class Store { + items: Record; + constructor(items: Record) { + this.items = items; + } + get(key: string): T { + return this.items[key]; + } + pick(keys: K[], index: number): K { + return keys[index]; + } +} +const store = new Store({ a: 1 }); +console.log(store.get("a"), store.get("b"), store.pick(["x", "y"], 1), store.pick(["x"], 4)); + +const pairs: [string, number][] = [["a", 1]]; +function second(rows: T[][], row: number, column: number): T { + return rows[row][column]; +} +console.log(second([[1, 2]], 0, 1), second([[1, 2]], 0, 9), second(pairs, 0, 1));