Files
scriptc/tests/corpus/7565-iterable-replaceable-method-results.ts
T
Malte UblandClaude Opus 5.5 9a452b8129 Keep replaceable method results that have no dynamic form native
A method whose name an untyped write can replace is called through the
instance's property lookup: a ternary between the dynamic call of an
installed replacement and the native call, both boxed into a dynamic
value and converted back to the method's result type. A result with no
dynamic form, such as an Iterable<T> (its generator arm does not box),
stayed unboxed in the dynamic ternary, which failed IR validation with
"ternary else-branch: expected dyn, got union". Methods of generic class
instantiations always take this path once any write can replace their
name, because their prototype data is not modeled, so importing zod
(whose JavaScript has unknown-key writes) broke every such call.

Such a result now keeps its native type: the native call's value is used
as it is, and only a replacement's dynamic result takes the checked
conversion into the result type. Results that box are lowered as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 20:24:18 -07:00

77 lines
2.4 KiB
TypeScript

// A method returning Iterable<T> whose name an untyped write can replace
// (`holder.candidates = ...` through `any`) is called through the
// instance's property lookup: the native method runs while the slot is
// unchanged, and a replacement installed on an instance runs instead,
// its result iterated as an Iterable<T>.
interface Lookup {
readonly index: string;
readonly key: string;
}
interface Query {
readonly lookups?: readonly Lookup[];
}
interface Row {
id: string;
n: number;
}
class Repo<T extends { id: string }> {
private readonly rows = new Map<string, T>();
add(row: T): void {
this.rows.set(row.id, row);
}
candidates(lookups: readonly Lookup[]): Iterable<T> {
if (lookups.length === 0) return this.rows.values();
const out: T[] = [];
for (const row of this.rows.values()) if (lookups.every((l) => l.key === row.id)) out.push(row);
return out;
}
query(query: Query): T[] {
const matches: T[] = [];
for (const row of this.candidates(query.lookups ?? [])) matches.push(row);
return matches;
}
ids(query: Query): string {
let out = "";
for (const row of this.candidates(query.lookups ?? [])) out += (out ? "," : "") + row.id;
return out;
}
}
function seed(): Repo<Row> {
const r = new Repo<Row>();
r.add({ id: "a", n: 1 });
r.add({ id: "b", n: 2 });
r.add({ id: "c", n: 3 });
return r;
}
const r = seed();
console.log(r.query({}).length, r.query({ lookups: [{ index: "i", key: "b" }] }));
console.log(r.ids({}), r.ids({ lookups: [{ index: "i", key: "c" }] }));
// An untyped write of the same name: on an unrelated object first, then
// on one Repo instance, whose own property shadows the class method.
const holder: any = {};
holder.candidates = (): Row[] => [];
console.log(typeof holder.candidates);
const replaced = seed();
const untyped: any = replaced;
untyped.candidates = function (lookups: readonly Lookup[]): Row[] {
return [{ id: "z" + lookups.length, n: 9 }];
};
console.log(replaced.query({}), replaced.query({ lookups: [{ index: "i", key: "a" }] }));
console.log(replaced.ids({}));
// A generator replacement is single-pass, as in JavaScript.
untyped.candidates = function* (): Generator<Row, void, undefined> {
yield { id: "g1", n: 10 };
yield { id: "g2", n: 11 };
};
console.log(replaced.query({}).map((row) => row.id + ":" + row.n));
// The untouched instance still runs the class method.
console.log(r.ids({}));