bastianplsfix

Async iteration

Async iteration is the iteration protocol from the iterables and iterators page with one change: next() returns a promise for an iterator result rather than a result. The method is Symbol.asyncIterator rather than Symbol.iterator, the loop is for await rather than for-of, and the producer is async function* rather than function*. Nothing else is new.

You reach for this when values arrive one at a time over time: lines from a file, chunks from a network response, rows from a query. A promise for an array makes you wait for all of it, where an async iterable gives you the first one as soon as it exists.

Create programs/async-iteration.test.ts for this reference and keep it open. Start it with the assertion helpers used on this page:

import {
assert,
assertEquals,
assertFalse,
assertRejects,
assertStrictEquals,
assertThrows,
} from "@std/assert";

Below the import, add the two-value async generator several steps share:

async function* twoLetters(): AsyncGenerator<string> {
yield "a";
yield "b";
}

Follow the page as you add and revise the runnable examples below it.

for await

Deno.test("for await", async () => {
const seen: string[] = [];
for await (const letter of twoLetters()) {
seen.push(letter);
}
assertEquals(seen, ["a", "b"]);

const plain: string[] = [];
for await (const letter of ["a", "b"]) {
plain.push(letter);
}
assertEquals(plain, ["a", "b"]);

const resolved: string[] = [];
for await (const letter of [Promise.resolve("a"), Promise.resolve("b")]) {
resolved.push(letter);
}
assertEquals(resolved, ["a", "b"]);
});
Check programs/async-iteration.test.ts
running 1 test from ./programs/async-iteration.test.ts
for await ... ok (336µs)

ok | 1 passed | 0 failed (1ms)

for await calls next(), awaits the promise, and hands you the value, once per turn of the loop; it goes anywhere await goes, inside an async function or at the top level of a module, from the async functions page. It also accepts a synchronous iterable and awaits each element, which is what the second and third loops show. The third is the reason that flexibility exists: an array of promises is something you will have, and for await walks it one at a time in order, the sequential counterpart to Promise.all.

Array.fromAsync drains one

Deno.test("Array.fromAsync drains one", async () => {
assertEquals(await Array.fromAsync(twoLetters()), ["a", "b"]);

assertEquals(
await Array.fromAsync([Promise.resolve(1), Promise.resolve(2)]),
[1, 2],
);
assertEquals(
await Array.fromAsync(twoLetters(), (letter, index) => `${index}${letter}`),
["0a", "1b"],
);
});
Array.fromAsync drains one ... ok (88µs)

Array.fromAsync is Array.from for async iterables: it drains the whole thing into an array and gives you a promise for it, with the same optional mapping function, index included. Use it when the whole result fits in memory and you want an array, and notice what it gives up, which is the entire point of streaming: nothing happens until everything has happened.

the promise wraps the whole result

The protocol change is one line, and its shape is forced. Write the protocol by hand, and read done without awaiting:

Deno.test("the promise wraps the whole result", () => {
const counted: AsyncIterable<number> = {
[Symbol.asyncIterator](): AsyncIterator<number> {
let n = 0;
return {
next: () =>
Promise.resolve(
n < 2
? { value: n++, done: false }
: { value: undefined, done: true },
),
};
},
};

const walker = counted[Symbol.asyncIterator]();
const pending = walker.next();
assertStrictEquals(pending.done, false);
});
Check programs/async-iteration.test.ts
TS2339 [ERROR]: Property 'done' does not exist on type 'Promise<IteratorResult<number, any>>'.
assertStrictEquals(pending.done, false);
~~~~
at file:///programs/async-iteration.test.ts:67:30

TS2773 [ERROR]: Did you forget to use 'await'?
assertStrictEquals(pending.done, false);
~~~~
at file:///programs/async-iteration.test.ts:67:30

error: Type checking failed.

Both diagnostics point the same way, and the second even asks the right question. There were two ways to design this: put a promise in value, or wrap the whole {value, done} object. It had to be the second, and the reason is a good one. Calling next() starts work, and until that work finishes you do not know whether it produced a value or reached the end, so done is as unknown as value is: a {value: Promise, done: false} would be a lie, since you cannot know done is false before asking. Hence one promise around both. Await it:

Deno.test("the promise wraps the whole result", async () => {
const counted: AsyncIterable<number> = {
[Symbol.asyncIterator](): AsyncIterator<number> {
let n = 0;
return {
next: () =>
Promise.resolve(
n < 2
? { value: n++, done: false }
: { value: undefined, done: true },
),
};
},
};

const walker = counted[Symbol.asyncIterator]();
const pending = walker.next();

assert(pending instanceof Promise);
assertEquals(await pending, { value: 0, done: false });
assertEquals(await walker.next(), { value: 1, done: false });
assertEquals(await walker.next(), { value: undefined, done: true });
});
the promise wraps the whole result ... ok (67µs)

That is also the last hand-written async iterator on this page, because an async generator does all of the bookkeeping for you, which is the next step.

an async generator is two features at once

Deno.test("an async generator is two features at once", async () => {
const work: string[] = [];

async function* source(): AsyncGenerator<string> {
for (const value of ["a", "b"]) {
work.push(`read ${value}`);
yield value;
}
}

async function* numbered(
input: AsyncIterable<string>,
): AsyncGenerator<string> {
let n = 1;
for await (const value of input) {
work.push(`number ${n}`);
yield `${n++}: ${value}`;
}
}

const walker = numbered(source());
assertEquals(work, []);

assertStrictEquals((await walker.next()).value, "1: a");
assertEquals(work, ["read a", "number 1"]);

assertStrictEquals((await walker.next()).value, "2: b");
assertEquals(work, ["read a", "number 1", "read b", "number 2"]);
});
an async generator is two features at once ... ok (91µs)

async function* is an async function on the way in and a generator on the way out, so it can for await its input and yield its output, and it produces an async iterable with none of the previous step's bookkeeping. Compare it with the synchronous pipeline in the generators page's composition stays lazy and the difference is two keywords: async before function*, and await in the loop head, which is the whole translation in both directions. The laziness carries over exactly. Building the pipeline read nothing, the first result arrived after one element had been read rather than all of them, and the two generators interleaved, the same log as the synchronous case, and here it is the difference between processing a large file and loading it.

yield* delegates to either kind

Deno.test("yield* delegates to either kind", async () => {
async function* inner(): AsyncGenerator<string> {
yield "b";
yield "c";
}

async function* outer(): AsyncGenerator<string> {
yield "a";
yield* inner();
yield* ["d", "e"];
}

assertEquals(await Array.fromAsync(outer()), ["a", "b", "c", "d", "e"]);
});
yield* delegates to either kind ... ok (48µs)

Inside an async generator, yield* accepts an async iterable or a synchronous one, awaiting as needed, which makes mixing sources painless: a literal chunk, then a stream, then another generator, in one body.

cleanup works, because it is still a generator

Deno.test("cleanup works, because it is still a generator", async () => {
const events: string[] = [];

async function* guarded(): AsyncGenerator<number> {
try {
yield 1;
yield 2;
} finally {
events.push("cleaned");
}
}

for await (const n of guarded()) {
events.push(`saw ${n}`);
break;
}
assertEquals(events, ["saw 1", "cleaned"]);

events.length = 0;
const walker = guarded();
await walker.next();
assertEquals(await walker.return(99), { value: 99, done: true });
assertEquals(events, ["cleaned"]);
});
cleanup works, because it is still a generator ... ok (41µs)

A try/finally around a yield runs its finally when a for await loop leaves early, exactly as the generators page measured in return and throw are how a generator is stopped, because break calls .return() on the iterator and that resumes the body as though a return stood at the yield. It matters more here than it does synchronously: an async generator is usually holding something, a file, a connection, a reader, and finally is where you release it, so a break in somebody else's loop will run it.

a rejection throws out of the loop and stops it

Three elements, one of them rejected. Predict what the loop collects:

Deno.test("a rejection throws out of the loop and stops it", async () => {
const seen: number[] = [];

await assertRejects(
async () => {
for await (
const n of [
Promise.resolve(1),
Promise.reject(new Error("bad element")),
Promise.resolve(3),
]
) {
seen.push(n);
}
},
Error,
"bad element",
);

assertEquals(seen, [1, 3]);
});
Check programs/async-iteration.test.ts
running 7 tests from ./programs/async-iteration.test.ts
...
a rejection throws out of the loop and stops it ... FAILED (8ms)

ERRORS

a rejection throws out of the loop and stops it => ./programs/async-iteration.test.ts:148:6
error: AssertionError: Values are not equal.

[Diff] Actual / Expected

[
1,
+ 3,
]

FAILURES

a rejection throws out of the loop and stops it => ./programs/async-iteration.test.ts:148:6

FAILED | 6 passed | 1 failed (10ms)

error: Test failed

The loop ended. A rejected element throws where the loop is written, so try/catch around the loop works, and assertRejects caught it here, but element three was never visited. That is the right default and often not what you want, since one unreadable line should rarely abandon a file. Two ways out: a try/catch inside the loop body, around the processing that can fail, which keeps the loop going; or Promise.allSettled when the values are a fixed collection rather than a stream, from the promise combinators page. Correct the prediction to [1]:

a rejection throws out of the loop and stops it ... ok (323µs)

there are no async iterator helpers

The iterator helpers page gave every synchronous iterator a shared prototype full of methods. Reach for the async equivalent:

Deno.test("there are no async iterator helpers", async () => {
const doubled = await Array.fromAsync(twoLetters().map((s) => s + s));
assertEquals(doubled, ["aa", "bb"]);
});
Check programs/async-iteration.test.ts
TS2339 [ERROR]: Property 'map' does not exist on type 'AsyncGenerator<string, any, any>'.
const doubled = await Array.fromAsync(twoLetters().map((s) => s + s));
~~~
at file:///programs/async-iteration.test.ts:172:54

TS7006 [ERROR]: Parameter 's' implicitly has an 'any' type.
const doubled = await Array.fromAsync(twoLetters().map((s) => s + s));
^
at file:///programs/async-iteration.test.ts:172:59

Found 2 errors.

error: Type checking failed.

There is none. The async side has no map, no filter, no toArray, and no AsyncIterator global to reach for either, which is the asymmetry to know about because it is the opposite of what you would guess. Pin the absence at the prototype itself:

Deno.test("there are no async iterator helpers", () => {
const walker = twoLetters();
const shared = Object.getPrototypeOf(Object.getPrototypeOf(walker));

assertFalse("map" in shared);
assertFalse("filter" in shared);
assertFalse("toArray" in shared);
assert(Symbol.asyncIterator in shared);

assertEquals(["a", "b"].values().map((s) => s + s).toArray(), ["aa", "bb"]);
});
there are no async iterator helpers ... ok (239µs)

So the tidy pipeline you can write over a synchronous iterator has no async equivalent, and the only way to transform an async iterable is to write an async generator that consumes one and yields another. Two mitigations. Array.fromAsync covers give-me-all-of-it, which is the most common case. And an async generator is genuinely not hard to write, just longer than a method call, which the closing step shows.

for await is only valid where await is

Deno.test("for await is only valid where await is", () => {
assertThrows(
() => new Function("function sync() { for await (const x of []) {} }"),
SyntaxError,
"Unexpected reserved word",
);
});
for await is only valid where await is ... ok (89µs)

Same rule as await itself, since for await is await in a loop head, observed the usual way, by compiling a string. The message is less helpful than the plain-await one the async functions page quotes, so recognise it: Unexpected reserved word in a for loop means the enclosing function is not async.

a ReadableStream is an async iterable

Deno.test("a ReadableStream is an async iterable", async () => {
function streamOf<T>(...values: T[]): ReadableStream<T> {
return ReadableStream.from(values);
}

const chunks: string[] = [];
for await (const chunk of streamOf("first ", "second")) {
chunks.push(chunk);
}
assertEquals(chunks, ["first ", "second"]);

assertEquals(await Array.fromAsync(streamOf(1, 2)), [1, 2]);
});
a ReadableStream is an async iterable ... ok (1ms)

This is where the feature stops being theoretical. A ReadableStream implements Symbol.asyncIterator, and in Deno that means a file's contents, a fetch response body, Deno.stdin, and anything you build with a TransformStream can all be read with for await; ReadableStream.from builds a small one for the tests. It also means the reader is in control. A callback-based stream pushes chunks at you as fast as they arrive, where for await pulls them, one per turn of the loop, and stops when you break, the same pull-against-push argument the generators page makes, now against a real API.

chunks in, lines out

Deno.test("chunks in, lines out", async () => {
function streamOf<T>(...values: T[]): ReadableStream<T> {
return ReadableStream.from(values);
}

async function* toLines(
chunks: AsyncIterable<string>,
): AsyncGenerator<string> {
let held = "";
for await (const chunk of chunks) {
held += chunk;
let newline = held.indexOf("\n");
while (newline >= 0) {
yield held.slice(0, newline + 1);
held = held.slice(newline + 1);
newline = held.indexOf("\n");
}
}
if (held.length > 0) yield held;
}

async function* number(
lines: AsyncIterable<string>,
): AsyncGenerator<string> {
let n = 1;
for await (const line of lines) {
yield `${n++}: ${line}`;
}
}

const chunks = streamOf("First\nSec", "ond\nThird\nF", "ourth");

assertEquals(await Array.fromAsync(number(toLines(chunks))), [
"1: First\n",
"2: Second\n",
"3: Third\n",
"4: Fourth",
]);

async function* filterAsync<T>(
input: AsyncIterable<T>,
keep: (value: T) => boolean,
): AsyncGenerator<T> {
for await (const value of input) {
if (keep(value)) yield value;
}
}

assertEquals(
await Array.fromAsync(
filterAsync(streamOf(1, 2, 3, 4), (n) => n % 2 === 0),
),
[2, 4],
);
});
chunks in, lines out ... ok (202µs)

Chunks in, lines out, with the leftover carried across chunk boundaries and whatever remains at the end yielded even without a trailing newline. That is the whole shape of stream processing in this language, and the pieces compose: two generators stacked, and neither knows about the other or about where the chunks came from. In real code the Array.fromAsync at the end would be a for await, and nothing would ever hold more than one line. And filterAsync is the missing-helper shape, written once: six lines, for await in, yield out, which is every absent method from the asymmetry step.

In practice