Typing Iterators and Iterables in TypeScript
Introduction
Iterators and iterables are foundational abstractions in JavaScript and TypeScript. They power for...of loops, generators, and many streaming APIs. But when TypeScript developers try to type custom iterators or consume iterator-producing libraries, they often hit confusing edges: how to express yielded and returned types, how to type async iterators, and how to combine generics with the built-in iterator interfaces. This tutorial aims to demystify these topics for intermediate TypeScript developers.
In this guide you'll learn practical patterns for typing synchronous and asynchronous iterators, creating strongly-typed generator functions, composing iterable types, and integrating iterators with other TypeScript features like type guards, conditional types, and const assertions. We'll include step-by-step examples, common pitfalls, debugging tips, and connections to related topics such as typing async generators, handling promise rejections, and using the satisfies operator to improve inference.
By the end you'll be able to:
- Implement custom iterators with correct type signatures
- Type generator-based APIs and async iterators
- Use helper utilities like IterableIterator and Symbol.iterator safely
- Compose iterators with generics and mapped types for complex data flows
This article is practical and example-driven. Code snippets are ready to copy and try in the TypeScript playground or a local project.
Background & Context
JavaScript introduced the iterator protocol to standardize how values are produced sequentially. An iterator is an object that implements a next method returning objects of shape { value, done }. An iterable is any object that implements a method keyed by Symbol.iterator which returns an iterator. TypeScript ships builtin types like Iterator
Typing iterators well reduces runtime errors when consuming streams of data, makes APIs self-documenting, and improves IDE experience. Async iteration (Symbol.asyncIterator) extends the same protocol for asynchronous streams. Proper typing is especially important for libraries, data pipelines, and complex control flows that mix sync and async steps.
Typed iterators are also relevant when you type generators, callbacks that return iterables, or when modeling recursive and streaming data structures. For related patterns like async generators and streaming JSON parsing, check out our guide on async generator functions and iterators.
Key Takeaways
- Learn core iterator and iterable interfaces in TypeScript
- Type generator functions, including yielded and return types
- Implement custom IterableIterator and AsyncIterableIterator types
- Use type guards, satisfies, and const assertions to improve inference
- Avoid common pitfalls like incorrect done/value typing
- Integrate iterator typing with async error handling and third-party libs
Prerequisites & Setup
You should have:
- TypeScript 4.4+ installed, though newer features like satisfies require 4.9+
- Basic familiarity with generics, union types, and type guards
- Node.js and a small project or the TypeScript playground to run examples
Install TypeScript locally if needed:
npm install --save-dev typescript npx tsc --init
Note: if you plan to use the satisfies operator for improved inference, see our guide on satisfies operator for details and examples.
Main Tutorial Sections
1. The Basic Iterator and Iterable Types (hands-on)
TypeScript exposes these built-ins:
interface IteratorResult<T, TReturn = any> {
value: T | TReturn
done: boolean
}
interface Iterator<T, TReturn = any, TNext = unknown> {
next(value?: TNext): IteratorResult<T, TReturn>
}
interface Iterable<T> {
[Symbol.iterator](): Iterator<T>
}To create a simple iterable, implement Symbol.iterator and return an iterator:
function range(n: number): Iterable<number> {
return {
[Symbol.iterator]() {
let i = 0
return {
next() {
if (i < n) return { value: i++, done: false }
return { value: undefined as any, done: true }
}
}
}
}
}
for (const x of range(3)) console.log(x)Note how the iterator result when done can carry a return value. You can express that with generics on Iterator and IteratorResult.
2. Typing Generator Functions Correctly
Generators in TypeScript use the Generator interface: Generator<Yield, Return, Next>. For example:
function* numbers(): Generator<number, void, unknown> {
yield 1
yield 2
}
const g = numbers()
const first: IteratorResult<number, void> = g.next()If your generator accepts next() values or returns a final value, reflect that in types:
function* accumulate(): Generator<number, number, number> {
let total = 0
while (true) {
const add = yield total
if (add == null) return total
total += add
}
}Typing the three generic parameters improves safety and autocompletion in consumers.
3. Using IterableIterator and Utility Types
IterableIterator
function* letters(): IterableIterator<string> {
yield 'a'
yield 'b'
}
const it: IterableIterator<string> = letters()You can also compose mapped types on iterables:
type MapIterable<I extends Iterable<any>, R> = Iterable<R>
This becomes useful when building lazy transformation pipelines.
4. Typing Async Iterators and Async Iterables
Async iteration uses Symbol.asyncIterator and returns promises from next(). TypeScript defines AsyncIterator and AsyncIterable:
interface AsyncIterator<T, TReturn = any, TNext = unknown> {
next(value?: TNext): Promise<IteratorResult<T, TReturn>>
}
interface AsyncIterable<T> {
[Symbol.asyncIterator](): AsyncIterator<T>
}To see async generators in action, check our deep dive on async generator functions and iterators which explores common patterns, typing pitfalls, and examples for Node and browsers.
Example async generator:
async function* asyncRange(n: number): AsyncIterable<number> {
for (let i = 0; i < n; i++) {
await new Promise((r) => setTimeout(r, 10))
yield i
}
}
for await (const v of asyncRange(3)) console.log(v)5. Typing Iterators from Third-Party Libraries
When wrapping libraries that produce iterators, you may lack proper types. In that case prefer to augment types or write thin adapter wrappers with explicit typing. Our guide on typing third-party libraries shows patterns for adding types and runtime guards.
Example: wrapping an untyped stream that yields JSON objects
type Item = { id: number; name: string }
function wrapStream(raw: any): IterableIterator<Item> {
const it: Iterator<Item> = raw[Symbol.iterator]()
// assert and adapt elements
return {
[Symbol.iterator]() { return this },
next() {
const r = it.next()
if (r.done) return { value: undefined as any, done: true }
// runtime validation goes here
return { value: r.value as Item, done: false }
}
}
}This pattern keeps the public surface typed while leaving runtime checks where necessary.
6. Using Type Guards and Narrowing with Iterators
Type guards help narrow the type of yielded values when an iterator can produce multiple shapes. Prefer type-guard functions and discriminated unions.
type Shape = { kind: 'a'; a: number } | { kind: 'b'; b: string }
function isA(s: Shape): s is Extract<Shape, { kind: 'a' }> {
return s.kind === 'a'
}
function* shapes(): Generator<Shape> {
yield { kind: 'a', a: 1 }
yield { kind: 'b', b: 'x' }
}
for (const s of shapes()) {
if (isA(s)) {
// s.a is available
}
}For guidance on when to use type assertions vs guards vs narrowing, see type guards and narrowing.
7. Combining Iterators with Generics and Higher-Order Functions
Make reusable iterator utilities using generics. Example: a lazy map over an Iterable
function mapIterable<T, R>(src: Iterable<T>, fn: (t: T) => R): Iterable<R> {
return {
[Symbol.iterator]() {
const it = src[Symbol.iterator]()
return {
next() {
const r = it.next()
if (r.done) return { value: undefined as any, done: true }
return { value: fn(r.value), done: false }
}
}
}
}
}This keeps iteration lazy and composable.
8. Recursive Iterables and Tree Traversal
Recursive types help model trees and graphs that yield values during traversal. See our guide on recursive data structures for more patterns. Example depth-first generator:
type Node<T> = { value: T; children?: Node<T>[] }
function* dfs<T>(node: Node<T>): IterableIterator<T> {
yield node.value
if (node.children) {
for (const c of node.children) yield* dfs(c)
}
}Using yield* with a properly typed IterableIterator keeps types accurate across recursive calls.
9. Streaming JSON and Backpressure Considerations
When working with streams (for example parsing NDJSON), iterators are a natural fit. Typing the pipeline prevents surprises. Also consider error handling: async iterators can reject via promises, so combine with typed errors. Our articles on typing Promise rejections and typing error objects provide patterns to make error types part of your contracts.
Example: typed async parser
type Event = { type: 'data'; payload: any } | { type: 'end' }
async function* parseLines(stream: AsyncIterable<string>): AsyncIterable<Event> {
for await (const line of stream) {
try {
yield { type: 'data', payload: JSON.parse(line) }
} catch (e) {
// throw or yield an error event depending on design
throw e
}
}
yield { type: 'end' }
}10. Leveraging const Assertions and satisfies for Better Inference
Use const assertions for literal tuples used in iteration and satisfies for structural checks. For details on when to use const assertions, see const assertions (as const). Example:
const pairs = [[1, 'a'], [2, 'b']] as const
// use satisfies to ensure a function expects a specific iterable shape
const adapter = {
[Symbol.iterator]: function* () {
for (const [n, s] of pairs) yield { n, s }
}
} satisfies Iterable<{ n: number; s: string }>This combination gives literal inference without weakening types.
Advanced Techniques
Advanced typing patterns let you model complex iterator behaviors. Consider these expert tips:
- Use conditional types to extract item types from an iterable: type ItemType = I extends Iterable
? U : never. - Model pipelines with variadic tuple types to infer successive transformations.
- Combine mapped types with IteratorResult to express finished vs yielded value types precisely.
- When exposing public APIs, prefer explicit interface types over implicitly inferred generator return types for stability.
- For runtime-heavy libraries, use runtime validators and keep types as documentation, but assert when necessary.
When integrating async iteration with error handling, model rejection types explicitly and document when an iterator may throw. For deep dives on async generator typing and patterns, see async generator functions and iterators.
Best Practices & Common Pitfalls
Dos:
- Do type both yielded and return types of generators: Generator<Y, R, N>.
- Do implement Symbol.iterator when you want for...of support.
- Do use IterableIterator
for generator-based iterables that are also directly iterable. - Do use type guards to narrow heterogeneous yielded types.
Don'ts:
- Don't return inconsistent types from next; prefer a consistent IteratorResult shape.
- Don't rely on implicit any for produced values in public APIs.
- Don't forget to type async iterators separately with AsyncIterable/AsyncIterator.
Troubleshooting tips:
- If inference gets confusing, annotate generator return types explicitly.
- Use the satisfies operator to keep inference while validating shapes; see satisfies operator.
- When wrapping untyped libraries, add runtime checks and keep a typed facade. See typing third-party libraries for patterns.
Real-World Applications
Iterators and iterables appear in many real systems:
- Lazy evaluation pipelines for data processing where you avoid loading entire datasets into memory.
- Stream parsing of logs, NDJSON, or CSV via async iterables.
- Graph and tree traversals using generator-based DFS or BFS.
- Integration with web APIs that supply streaming bodies (fetch streams) or Node stream utilities.
For example, an ETL pipeline can be implemented as a set of composable iterables: a source async iterable, a set of lazy transform iterables, and a sink consumer. Strong typing ensures that each stage receives the expected shape and reduces runtime surprises.
Conclusion & Next Steps
Typing iterators and iterables in TypeScript unlocks safer, more maintainable streaming and lazy APIs. Start by modeling your basic sync iterators, then add generic parameters and explicit generator type annotations. Move on to async iterators when working with IO-bound streams. Use type guards, const assertions, and satisfies where appropriate to boost inference and stability.
Next steps:
- Practice by converting an untyped streaming utility to typed iterables
- Read the async generators guide for advanced async patterns: async generator functions and iterators
- Explore related typing patterns for errors and third-party integration
Enhanced FAQ
Q1: What is the difference between Iterator
A1: Iterator
Q2: How do I type the values returned by next when an iterator finishes?
A2: Use IteratorResult<T, TReturn>. The second generic parameter TReturn models the returned value when done is true. Many implementations set TReturn = void when there is no meaningful return value. For generators, use Generator<Y, R, N> where R is the return type.
Q3: How do I type an async iterator that yields items and returns a final result?
A3: Use AsyncIterator<T, TReturn, TNext> and AsyncIterable
Q4: What about mixing sync and async iteration in the same codebase?
A4: Keep the concerns separate. Prefer sync iterables when no I/O is involved. For I/O or awaits between yields, use async iterables. If you must allow both, provide separate API methods or adapters to convert between async and sync boundaries, but document behavior clearly.
Q5: How should errors be modeled in iterators and async iterators?
A5: Async iterator next methods return promises which can reject, so document and type expected rejection shapes. Combine this with typed Error objects or discriminated error events if you prefer streaming error values. See our articles on typing Promise rejections and typing error objects for guidance.
Q6: Can I yield values of multiple shapes from the same generator?
A6: Yes. Model the union type of all possible yielded shapes. Use discriminated unions and type guards to narrow at consumption sites. This is safer than using any and keeps IntelliSense helpful.
Q7: How do I get better type inference when creating arrays or tuples used by iterators?
A7: Use const assertions (as const) to preserve literal types for tuples and arrays. Also consider using the satisfies operator to check structural shape while preserving inference. See const assertions (as const) and satisfies operator for examples.
Q8: How can I type lazy map/filter/reduce utilities for iterables?
A8: Use generics over the input iterable item type and return Iterable
function filterIterable<T>(src: Iterable<T>, pred: (t: T) => boolean): Iterable<T> { ... }Annotate the transform functions to help inference across chained calls.
Q9: What are common pitfalls when converting untyped iterator-producing libs?
A9: Common issues include assuming the iterator returns consistent shapes, missing async boundaries, and ignoring thrown errors. Wrap untyped inputs with typed facades that validate shapes at runtime, and keep the public API typed. For strategies, see typing third-party libraries.
Q10: How do generators interact with the for...of loop and yield*?
A10: for...of consumes the iterator returned by Symbol.iterator. yield* forwards values from an inner iterable to the outer generator, preserving types when inner iterables are properly typed. If the inner iterable returns a final value, yield* can capture that return value - type it accordingly using the generator's return generic.
If you want more examples or a walkthrough converting a specific untyped iterator to strongly typed code, tell me about the code and I can provide a step-by-step rewrite and tests.
