Relax the async-iterator protocol demand in untyped JavaScript

TS2504 demands a `[Symbol.asyncIterator]()` method that returns an async
iterator. In untyped JavaScript, whether a value is async-iterable is a
runtime fact of the value rather than a static one, exactly like the sync
demand (2488) relaxed just above. `for await` over a non-async-iterable
and `yield*` in an async generator both lower through the runtime
protocol openers, which read the method, call it, and throw the same
TypeError Node throws when it is missing, uncallable, or returns a
non-object.

Only diagnostics change, so only variants that previously held a 2504
refusal can move: the relaxed set is consulted only when JavaScript
strictness is suppressed, and 2504 is the sole code this adds. The sync
sibling 2505 stays enforced on the typed lanes, and TypeScript sources
keep 2504 because the relaxation applies to `.js`/`.mjs`/`.cjs`/`.jsx`
only.

Corpus 8423-async-iterator-protocol-shapes.js pins the seven protocol
shapes Node rejects and the two it accepts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Malte Ubl
2026-10-29 05:34:46 -07:00
co-authored by Claude Opus 5.5
parent a4c19ae4b4
commit c6db09c8e4
4 changed files with 127 additions and 0 deletions
@@ -94,3 +94,23 @@ test.for([
invalid,
);
});
test("JavaScript async generators may delegate over a runtime async-iterator shape", () => {
expect(
diagnostics(
`export async function* delegated() { yield* { [Symbol.asyncIterator]: 42 }; }
export async function* looped() { for await (const value of { [Symbol.asyncIterator]() { return "text"; } }) console.dir(value); }`,
'import { delegated, looped } from "./library.js"; delegated(); looped();',
),
).toEqual([]);
});
test("TypeScript keeps the async-iterator demand", () => {
expect(
diagnostics(
"export async function* delegated() { yield* { [Symbol.asyncIterator]: 42 }; }",
'import { delegated } from "./library.js"; delegated();',
"ts",
).some((diagnostic) => diagnostic.message.includes("Symbol.asyncIterator")),
).toBe(true);
});
@@ -109,6 +109,16 @@ export const JS_RELAXED_TSC_CODES: ReadonlySet<number> = new Set([
2488,
2721,
2722,
// the async-iterator protocol demand (2504: "must have a
// '[Symbol.asyncIterator]()' method that returns an async iterator").
// Whether a value is async-iterable is a runtime fact of untyped JS, exactly
// like the sync demand (2488) relaxed above: `for await` over a non-iterable
// value and `yield*` in an async generator both lower through the runtime
// protocol openers, which read the method, call it, and throw TypeError at
// the site when it is missing, uncallable, or returns a non-object — the
// same protocol failure Node reports. 2505 is the sync sibling on the typed
// lowering lanes and stays enforced.
2504,
// an UNUSED @ts-expect-error: the directive annotated an error visible
// under the project's OWN tsconfig world — scriptc's world deliberately
// differs (adopted options, hidden declaration twins), and an unused
@@ -0,0 +1,96 @@
// Async iteration over values whose [Symbol.asyncIterator] shape only exists
// at run time. JavaScript puts no static demand on the receiver: the protocol
// reads the method, calls it, and throws TypeError when the method is missing,
// is not callable, or returns a non-object. The checker must not refuse the
// source for the shapes inference cannot prove.
function report(label, fn) {
return Promise.resolve()
.then(fn)
.then(
(value) => console.log(label, "ok", value),
(error) => console.log(label, error instanceof TypeError, error.name),
);
}
async function* delegate(value) {
yield* value;
}
async function drain(value) {
const values = [];
for await (const item of value) values.push(item);
return values.join(",");
}
async function main() {
// A well-formed async iterator written inline still delegates.
await report("inline", () =>
drain({
async *[Symbol.asyncIterator]() {
yield "a";
yield "b";
},
}),
);
// The method is a data property, not a function.
await report("method-not-callable", () => drain({ [Symbol.asyncIterator]: 42 }));
// The method returns a number instead of an iterator object.
await report("result-not-object", () => drain({ [Symbol.asyncIterator]() { return 42; } }));
// The returned iterator's next is not callable.
await report("next-not-callable", () =>
drain({
[Symbol.asyncIterator]() {
return { next: "nope" };
},
}),
);
// No async iterator at all and no sync fallback either.
await report("no-protocol", () => drain({ value: 1 }));
// yield* delegates through the same protocol, so it throws the same way.
await report("yield-star-not-callable", async () => {
let seen = "";
for await (const value of delegate({ [Symbol.asyncIterator]: true })) seen += value;
return seen;
});
await report("yield-star-result-not-object", async () => {
let seen = "";
for await (const value of delegate({ [Symbol.asyncIterator]() { return "text"; } })) seen += value;
return seen;
});
// A non-callable async iterator wins over a working sync iterator: the
// protocol only falls back when the method is undefined.
await report("async-shadows-sync", () =>
drain({
[Symbol.asyncIterator]: 0,
*[Symbol.iterator]() {
yield "never";
},
}),
);
// A value with only a sync iterable iterates through the sync fallback.
await report("sync-only", () =>
drain({
*[Symbol.iterator]() {
yield "s1";
yield "s2";
},
}),
);
// yield* over arrays and strings keeps working.
await report("yield-star-array", async () => {
let seen = "";
for await (const value of delegate([1, 2])) seen += value;
return seen;
});
}
main();
@@ -0,0 +1 @@
{ "plain": 8004 }