Return the Set from add() when its result is used

`s.add(a).add(b)`, `s.add(x) === s`, `return s.add(x)` and other reads of
add()'s result were refused with "chained Set method calls": the lowered
add was a void intrinsic. A used result now reads the receiver once into a
slot, adds, and answers the slot, as Map's set() already does. Discarded
results (statements, arrow expression bodies, `void`, for headers, comma
left operands) keep the plain intrinsic, so existing programs compile
unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Malte Ubl
2026-10-20 05:41:23 -07:00
co-authored by Claude Opus 5.5
parent e1c27176a6
commit 205b552104
3 changed files with 105 additions and 3 deletions
@@ -524,6 +524,36 @@ export function lowerMapForEachCall(
return { kind: "call", callee: helper, args: [receiver, fnArg], type: VOID, loc };
}
/** True when nothing reads an add() call's result: an expression
* statement, an arrow's expression body, a `void` operand, a for header's
* initializer or incrementor, a comma's left operand, or a conditional or
* comma result that is itself discarded. */
function setAddResultDiscarded(call: ts.CallExpression): boolean {
let node: ts.Node = call;
for (;;) {
let parent = node.parent;
while (parent !== undefined && ts.isParenthesizedExpression(parent)) {
node = parent;
parent = parent.parent;
}
if (parent === undefined) return false;
if (ts.isExpressionStatement(parent) || ts.isVoidExpression(parent)) return true;
if (ts.isArrowFunction(parent)) return parent.body === node;
if (ts.isForStatement(parent))
return parent.initializer === node || parent.incrementor === node;
if (ts.isBinaryExpression(parent) && parent.operatorToken.kind === ts.SyntaxKind.CommaToken) {
if (parent.left === node) return true;
node = parent;
continue;
}
if (ts.isConditionalExpression(parent) && parent.condition !== node) {
node = parent;
continue;
}
return false;
}
}
/** Ambient Set method calls — Map's lowering with the value slot gone.
* `add`/`has`/`delete`/`clear` lower to setIntrinsic; `forEach` desugars
* like Map's over the shared iteration primitives. Null when this isn't
@@ -558,8 +588,8 @@ export function lowerSetMethodCall(
const value = lowerer.lowerExpr(access.expression);
const receiver =
lowerer.runtimeOptionalPropertyReceiver(access.expression, value, receiverIr, name) ?? value;
// The lib's `add` returns the Set (chaining typechecks); the lowered
// add is a void statement — fence chained receivers like Map's set.
// A used add() result lowers to its receiver (below), so a chained
// receiver is a Set; anything else lowering to a non-Set stays fenced.
if (receiver.type.kind !== "set") {
lowerer.noLowering(
"chained Set method calls",
@@ -582,7 +612,28 @@ export function lowerSetMethodCall(
}
if (name === "add") {
const v = lowerer.lowerCollectionKey(call.arguments.nodes[0]!, receiverIr.elem);
return { kind: "setIntrinsic", method: "add", receiver, args: [v], type: VOID, loc };
if (setAddResultDiscarded(call)) {
return { kind: "setIntrinsic", method: "add", receiver, args: [v], type: VOID, loc };
}
// add() returns its Set: a used result (`s.add(a).add(b)`,
// `s.add(x) === s`) reads the receiver once into a slot, adds, and
// answers the slot, as Map's set() does.
const slot = lowerer.declareHiddenLocal("%setAddReceiver", receiver.type);
const ref = varRef(slot.id, receiver.type, loc);
return {
kind: "seqExpr",
stmts: [
{ kind: "varDecl", localId: slot.id, init: receiver, loc },
{
kind: "exprStmt",
expr: { kind: "setIntrinsic", method: "add", receiver: ref, args: [v], type: VOID, loc },
loc,
},
],
result: ref,
type: receiver.type,
loc,
};
}
if (name === "has" || name === "delete") {
const v = lowerer.lowerCollectionKey(call.arguments.nodes[0]!, receiverIr.elem);
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/6481-set-add-chaining.ts"
],
"diags": []
}
+45
View File
@@ -0,0 +1,45 @@
// Set.prototype.add returns the Set, so its result can be chained,
// compared, stored and returned. Results nobody reads keep the plain
// statement form.
class Item {
name: string;
constructor(name: string) {
this.name = name;
}
}
const seen = new Set<string>();
seen.add("a").add("b").add("a");
console.log(seen.size, [...seen].join(","));
console.log(seen.add("c") === seen, seen.has("c"));
const nodes = new Set<Item>();
const first = new Item("first");
const memo = new Item("memo");
nodes.add(first).add(memo);
console.log(nodes.size, nodes.has(first), nodes.has(memo));
function collect(values: number[]): Set<number> {
const out = new Set<number>();
for (const v of values) out.add(v);
return out.add(-1);
}
console.log([...collect([3, 1, 3, 2])].join(" "));
const stored = seen.add("d");
console.log(stored === seen, stored.size);
let calls = 0;
function target(): Set<string> {
calls++;
return seen;
}
target().add("e").add("f");
console.log(calls, seen.size);
const flag = seen.size > 3;
const picked = flag ? seen.add("g") : new Set<string>();
console.log(picked === seen, seen.has("g"));
[1, 2, 2, 5].forEach((n) => nodes.add(new Item(`n${n}`)));
console.log(nodes.size);