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>
This commit is contained in:
Chris Tate
2026-10-18 11:04:03 -05:00
parent 307387c8ea
commit 56e472a225
5 changed files with 163 additions and 2 deletions
@@ -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 => {
@@ -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
@@ -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<ts.Symbol>();
/** 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<ts.Node>();
/** Concise callback/lambda returns promoted by the HOF callback prepass;
* arrows have no declaration symbol to key in fnSigsBySymbol. */
readonly runtimeOptionalFunctionReturns = new Map<ts.Node, IrType>();
@@ -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
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/5954-generic-optional-returns.ts"
],
"diags": []
}
@@ -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<T>(items: T[], index: number): T {
return items[index];
}
function lookup<T>(table: Record<string, T>, key: string): T {
return table[key];
}
function viaBinding<T>(items: T[], index: number): T {
const item = items[index];
return item;
}
function either<T>(items: T[], index: number, fallback: T): T {
return index >= 0 ? items[index] : fallback;
}
const first = <T,>(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<number>([]), 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<T> {
items: Record<string, T>;
constructor(items: Record<string, T>) {
this.items = items;
}
get(key: string): T {
return this.items[key];
}
pick<K extends string>(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<T>(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));