Using for...of and for await...of with Typed Iterables in TypeScript
Introduction
Iteration is a core programming concept: processing arrays, streaming data, or reading results from an async source. In JavaScript and TypeScript, for...of and for await...of are expressive, concise constructs for working with iterables and async iterables. But when types become complex — custom iterators, DOM collections, async streams, or external APIs — naive usage can lead to confusing types, runtime surprises, or silent bugs.
This tutorial teaches intermediate TypeScript developers how to work with both synchronous and asynchronous iteration in a fully typed way. You will learn the Iterable and AsyncIterable protocols, implement typed custom iterators and generator functions, consume async streams safely with for await...of, and handle errors and cancellation patterns. We'll include numerous practical code samples, step-by-step instructions, and tips to avoid common pitfalls.
By the end of this guide you will be able to:
- Correctly annotate iterables and iterators with generics.
- Implement typed custom iterator classes and generator functions.
- Use for await...of safely with AsyncIterable
and async generators. - Handle typed errors and rejected promises produced inside async iteration.
- Optimize iteration for performance and memory usage.
This article assumes you are comfortable with TypeScript basics, generics, and async/await. It focuses on real-world patterns you can adopt immediately.
Background & Context
JavaScript's iteration protocol is simple: an object is iterable if it implements a method keyed by Symbol.iterator that returns an iterator. An iterator supplies next() calls that yield { value, done } pairs. For asynchronous data, the protocol uses Symbol.asyncIterator and returns promises of results. TypeScript models these behaviors with Iterable
Typed iteration matters for safety and refactorability. Without proper typing, a for...of loop can implicitly widen to any, making code brittle and hiding errors. When consuming external APIs, streams, or DOM collections, explicit types reduce runtime surprises and improve editor experience (autocompletion, rename safety, and refactor tools). For advanced cases you will often mix iterables with promises, generator functions, and classes that implement constructors and static members — all areas that benefit from patterns described in related TypeScript guides.
For details on asynchronous generators and their TypeScript typings, check our guide on Typing Asynchronous Generator Functions and Iterators in TypeScript.
Key Takeaways
- for...of works with Iterable
; for await...of works with AsyncIterable or Promises of iterables. - Generators and async generators provide ergonomic ways to implement (async) iterables.
- Prefer explicit generic annotations for iterables to improve inference and DX.
- Handle errors from async iteration with typed rejections and runtime guards.
- Avoid mixing synchronous and asynchronous protocols unintentionally.
Prerequisites & Setup
Before diving in, ensure your environment supports the targets required by async iteration. In tsconfig.json, target should be at least ES2018 for async iteration transpilation helpers, or use a modern bundler. Install TypeScript 4.x or later to get the best inference for generator and async generator types.
If you use DOM iteration (NodeList, HTMLCollections) or Node.js streams, ensure your lib settings include the DOM and ES2019 (or later) libs. For Node-specific stream examples, consult the guide on Typing Node.js Built-in Modules in TypeScript.
Familiarity with generator functions (function*), async/await, and basic TypeScript generics is required. For patterns that help with object shape inference, you may find Using the satisfies Operator in TypeScript (TS 4.9+) helpful when designing typed iterables.
Main Tutorial Sections
1) Understanding the Iterable and Iterator Protocols
The core building blocks are these interfaces:
interface IteratorResult<T> { value: T; done: boolean }
interface Iterator<T> { next(): IteratorResult<T> }
interface Iterable<T> { [Symbol.iterator](): Iterator<T> }
interface AsyncIterator<T> { next(): Promise<IteratorResult<T>> }
interface AsyncIterable<T> { [Symbol.asyncIterator](): AsyncIterator<T> }In practice use the built-in types in TypeScript: Iterable
function logAll<T>(it: Iterable<T>) {
for (const item of it) {
console.log(item); // item is T
}
}This small step prevents accidental any widening and helps editors provide correct completions.
2) Typing native synchronous iterables: arrays, Set, Map
Arrays, Set, and Map implement Iterable
function sum(values: Iterable<number>): number {
let total = 0
for (const n of values) total += n
return total
}
sum([1, 2, 3])
sum(new Set([4, 5]))Maps iterate over entries by default. If you need keys or values explicitly, use map.keys() or map.values() with correct types. This reduces surprises when passing a Map to a function expecting Iterable
3) Iterating DOM collections with types
DOM collections like NodeList are iterable in modern browsers, but typing can be unclear in TypeScript projects with complex DOM types. When iterating NodeListOf
function findTextNodes(nodes: Iterable<Node>) {
for (const node of nodes) {
if (node.nodeType === Node.TEXT_NODE) {
// node is still Node; narrow by guards
}
}
}
const items = document.querySelectorAll('.item') as NodeListOf<Element>
for (const el of items) {
// el: Element
}If you are extending global window types or adding custom globals used by iteration, see Typing Global Variables: Extending the window Object in TypeScript for safe patterns.
4) Implementing typed custom iterables with classes
A common pattern is making a class that implements Iterable
class Range implements Iterable<number> {
constructor(public start: number, public end: number) {}
[Symbol.iterator]() {
let current = this.start
const end = this.end
return {
next(): IteratorResult<number> {
if (current <= end) return { value: current++, done: false }
return { value: undefined as any, done: true }
}
}
}
}
for (const n of new Range(1, 3)) console.log(n)If your class has nontrivial construction logic or generic constraints, consult patterns for constructors in TypeScript like Typing Class Constructors in TypeScript — A Comprehensive Guide.
5) Generators: ergonomic custom iterables
Generators provide a compact syntax to implement iterables and are well-typed in TypeScript:
function* rangeGen(start: number, end: number): Generator<number> {
for (let i = start; i <= end; i++) yield i
}
for (const n of rangeGen(1, 3)) console.log(n) // typed as numberGenerator types are of the form Generator<Y, R, N> where Y is yielded type, R is return type, N is next() parameter type. For typical cases Generator
6) Async generators and for await...of
Async generators let you implement AsyncIterable
async function* asyncRange(start: number, end: number): AsyncGenerator<number> {
for (let i = start; i <= end; i++) {
await new Promise((r) => setTimeout(r, 10))
yield i
}
}
(async () => {
for await (const n of asyncRange(1, 3)) {
console.log(n) // n: number
}
})()Use async generators when the source produces values asynchronously (network responses, timers, file reads). For deep dives into typing async generators and iterators see Typing Asynchronous Generator Functions and Iterators in TypeScript.
7) Consuming async iterables from streams and APIs
A practical use of AsyncIterable is consuming Node or browser streams. In Node, readable streams can be consumed as async iterables; make sure TypeScript's lib includes the right stream types. Example pseudo-pattern:
async function consumeStream(stream: AsyncIterable<Buffer>) {
for await (const chunk of stream) {
// chunk: Buffer
processChunk(chunk)
}
}When fetching paginated APIs that return chunks or pages, you can adapt the response into an AsyncIterable
8) Error handling and typed rejections inside async iteration
Errors may come from the iterator itself or from awaited operations inside an async generator. To handle typed errors, annotate rejections where possible and use runtime guards for narrow types:
class NotFoundError extends Error {}
async function* loader(): AsyncGenerator<string, void, unknown> {
try {
const data = await fetchSomeData()
yield data
} catch (err) {
// Re-throw or yield a sentinel; keep types predictable
throw err
}
}
(async () => {
try {
for await (const item of loader()) {
console.log(item)
}
} catch (e) {
if (e instanceof NotFoundError) {
// handle typed error
}
}
})()For patterns to type Promise rejections and typed Error objects across your codebase, check Typing Promises That Reject with Specific Error Types in TypeScript and Typing Error Objects in TypeScript: Custom and Built-in Errors.
9) Advanced typing patterns: generics, conditional types, and satisfies
When composing utilities that accept both arrays and iterables, prefer generic constraints:
function first<T>(it: Iterable<T>): T | undefined {
for (const v of it) return v
return undefined
}If you build more sophisticated builders, conditional types can map input shapes to output types, and the satisfies operator can keep object literal inference while constraining shape. See Using the satisfies Operator in TypeScript (TS 4.9+) for examples that improve inference for iterable factories.
10) Integrating third-party iterables safely
Third-party libraries may provide iterable-like objects but without precise types. Wrap external data in type-safe adapters and runtime guards before iterating. See techniques in Typing Third-Party Libraries with Complex APIs — A Practical Guide for guard patterns and runtime checks.
Example adapter:
function asIterable<T>(input: unknown): Iterable<T> {
if (Array.isArray(input)) return input as T[]
throw new TypeError('Expected iterable')
}Wrap adapters early to get the rest of your code typed reliably.
Advanced Techniques
Here are expert tips for maximizing safety and performance when using typed iteration:
- Use async generators for backpressure-aware consumption. Yield control to consumers and await downstream readiness if necessary.
- Prefer streaming parsers that return AsyncIterable
rather than collecting whole responses into memory. For Node streams and file handling, consult Typing Node.js Built-in Modules in TypeScript. - When iterating large or infinite sequences, prefer generators to avoid allocating intermediate arrays. Combine with lazy functional utilities where needed.
- For typed error channels, consider returning result tuples like { ok: true, value } | { ok: false, error } to avoid thrown exceptions in streamed pipelines. This pattern makes typed downstream handling easier and explicit.
- Use AbortSignal to implement cancellable iterators. Accept an AbortSignal parameter and check signal.aborted inside loops.
These techniques make your iteration code robust, composable, and friendly to observability and cancellation.
Best Practices & Common Pitfalls
Dos:
- Annotate iterable function arguments explicitly with Iterable
or AsyncIterable to maintain strong inference. - Use generator and async generator types to express yields and return types precisely.
- Wrap untrusted sources with type-safe adapters or runtime validators, especially when handling JSON from external APIs.
Don'ts:
- Don’t mix sync and async protocols — for await...of expects an AsyncIterable or a value that resolves to one; feeding a sync Iterable without wrapping can cause runtime errors.
- Avoid returning Promise<Iterable
> in places where AsyncIterable is expected; instead, normalize to AsyncIterable by using async generators. - Don’t assume NodeList or HTMLCollection is always array-like in types; cast explicitly or use Array.from with proper generic annotations.
Troubleshooting tips:
- If TypeScript reports that a value is not iterable, check symbol keys: ensure [Symbol.iterator] or [Symbol.asyncIterator] is implemented and correctly typed.
- When inference is wrong, add explicit generic annotations to functions or variables. Use type assertions sparingly and prefer runtime guards where safety matters.
For more examples of dealing with built-in objects and DOM elements during iteration, see Typing Built-in Objects in TypeScript: Math, Date, RegExp, and More and Typing DOM Elements and Events in TypeScript (Advanced).
Real-World Applications
Typed iteration is useful across many scenarios:
- Processing database cursors: yield rows from a cursor as an AsyncIterable to stream results to clients.
- Consuming paginated APIs: turn page fetches into an AsyncIterable that yields individual items.
- Streaming logs or telemetry: implement backpressure-aware async generators to feed consumers.
- DOM-based tasks: process NodeList results or MutationObserver events as iterables in the browser. When extending or typing global event shapes, consult Typing Global Variables: Extending the window Object in TypeScript.
These patterns make your pipelines composable and type-safe from source to sink.
Conclusion & Next Steps
Typed iteration unlocks expressive, safe processing of synchronous and asynchronous data flows. Start by annotating parameters and return types with Iterable
If you liked this tutorial, continue with deeper topics: typed async generators, Promise rejection typing, and Node.js stream typings covered in the linked guides above.
Enhanced FAQ
Q1: When should I use for...of versus for await...of?
A1: Use for...of for synchronous iterables implementing Symbol.iterator (Array, Set, custom generators). Use for await...of for AsyncIterable
Q2: How do I type a function that accepts both arrays and other iterables?
A2: Use a generic constraint like function process
Q3: Can I use async generators to read Node streams? A3: Yes. Node Readable streams are AsyncIterable in modern Node versions. You can for await the stream directly or wrap any callback-based source into an async generator. For typings and patterns specific to Node modules, see Typing Node.js Built-in Modules in TypeScript.
Q4: How do I handle errors thrown inside an async generator while using for await...of? A4: Wrap the for await...of usage with try/catch. Errors thrown inside the generator propagate to the consumer. Alternatively, yield result objects like { ok: true, value } or { ok: false, error } to avoid exceptions and keep handling explicit and typed.
Q5: What are the performance considerations when iterating large datasets? A5: Avoid building large intermediate arrays. Use lazy generators or streams (AsyncIterable) to process data in chunks. Use backpressure mechanisms or pause/resume where supported (e.g., readable streams). Generators reduce allocations compared to mapping to new arrays.
Q6: How do I cancel a long-running async iterator? A6: Accept an AbortSignal in your iterator or generator and check signal.aborted inside the loop. When aborted, break or throw an error. Many libraries provide cancellation-aware iterables; otherwise, implement manual checks and cleanup.
Q7: What if a third-party API returns an object that looks iterable but TypeScript doesn't accept it? A7: Wrap it in an adapter that implements Symbol.iterator or Symbol.asyncIterator and add runtime guards. See Typing Third-Party Libraries with Complex APIs — A Practical Guide for patterns to adapt and validate shapes.
Q8: How should I type iterables that may yield different types over time? A8: Prefer union types such as Iterable<A | B>, or use discriminated union objects with a kind field so consumers can narrow with switch statements. For complex stateful sequences, define an interface for emitted items that carries explicit metadata to simplify narrowing.
Q9: Are there typing rules for generator next() parameter and return types? A9: Yes. Generator<Y, R, N> types describe yield type Y, return type R, and next parameter N. TypeScript infers these from generator implementations, but when inference fails you can annotate the function signature explicitly: function* g(): Generator<number, void, string> { ... }.
Q10: How do I type Promise rejections from async iterables so callers know what to expect? A10: TypeScript lacks built-in Promise rejection typing, so the practical approach is to either document and use runtime guards or adopt result-object patterns (ok/value or error) so the type system can represent both success and failure as part of the yielded type. For discussion and patterns, see Typing Promises That Reject with Specific Error Types in TypeScript and Typing Error Objects in TypeScript: Custom and Built-in Errors.
If you need help converting an existing codebase to typed iteration patterns, share a minimal example and I can propose a migration plan with concrete type annotations and adapter code.
