Typing Iterator Pattern Implementations (vs Built-in Iterators)
Introduction
Iterators are a fundamental abstraction in many programming languages. In JavaScript and TypeScript, built-in iterators power constructs such as for...of, Map, Set, and generator functions. But there is another important concept: the iterator design pattern, implemented by user code to provide custom traversal over a data structure, often with additional semantics or performance considerations. For intermediate TypeScript developers, typing these custom iterator pattern implementations correctly brings huge benefits: compile time safety, clearer APIs, and better DX for downstream users.
In this article you will learn how to design, implement, and type iterator pattern implementations in TypeScript. We cover the difference between built-in iterator protocols and the iterator pattern as a structural design. You will see practical examples including synchronous and asynchronous iterators, typed iterator factories, typed wrappers around native iterables, and patterns to enforce invariants at compile time. We will also touch on higher-order iterator utilities, tuple-based APIs, assertion functions, and performance tuning.
By the end of this guide you will be able to:
- Decide when to implement a custom iterator pattern vs using built-in iterators
- Create strongly typed iterator classes and factories
- Integrate async iterators and for await...of friendly types
- Build composable iterator utilities with robust TypeScript types
This tutorial assumes familiarity with TypeScript generics, basic iterator protocol, and async/await. If you need a refresher on typing iterators and iterables, check our primer on Typing Iterators and Iterables in TypeScript.
Background & Context
The iterator protocol in ECMAScript is simple and elegant: an object with a next method that returns { value, done }, and optionally a return and throw method. Generators provide a built-in way to produce such objects. But the iterator design pattern often refers to a custom object that encapsulates traversal logic for a collection or data source. Implementations vary: some wrap plain arrays, others stream data from disk or network, and some implement backpressure or pruning logic.
TypeScript's type system can capture much of this behavior so consumers get helpful errors and autocompletion. Proper typing becomes more important when iterators are combined with other patterns: async pipelines, memoization, caches, higher-order functions, or when using variadic argument patterns. We will connect many of these ideas to other typing topics like higher-order functions and async iterables covered in our guides such as Typing Async Iterators and Async Iterables in TypeScript — Practical Guide and Using for...of and for await...of with Typed Iterables in TypeScript.
Key Takeaways
- Understand the difference between native iterator protocol and iterator pattern implementations
- Use TypeScript generics to make iterator implementations reusable and type safe
- Type both sync and async iterator patterns for compatibility with for...of and for await...of
- Create typed iterator factories and wrappers to expose consistent APIs
- Combine iterators with higher-order functions while preserving types
- Use assertion functions and type predicates to validate runtime state and refine types
Prerequisites & Setup
Before proceeding you should have:
- Node 14+ or newer and a TypeScript toolchain (tsc 4.x+ recommended)
- Basic knowledge of generics, interfaces, and mapped types in TypeScript
- Familiarity with the built-in iterator protocol and generator functions
Create a minimal project to test code snippets:
- npm init -y
- npm i -D typescript
- npx tsc --init
- Enable strict mode in tsconfig.json for best results
If you need to review typing variadic arguments or higher order function typing, our guides on Typing Functions That Accept a Variable Number of Arguments (Tuples and Rest) and Typing Higher-Order Functions in TypeScript — Advanced Scenarios may be helpful.
Main Tutorial Sections
1) Iterator Pattern vs Built-in Iterators: When to Implement Custom Iterators
The built-in protocol is often sufficient. However, implement a custom iterator pattern when you need:
- Encapsulated traversal logic that is not strictly a sequence of values
- Stateful traversal with peek, rewind or snapshot capabilities
- Custom resource management such as opening/closing files or websockets
Example: a token stream that allows lookahead. The API may expose methods like next, peek, and reset. Typing this requires an interface that includes next returning an IteratorResult
interface TokenStream<T> extends Iterator<T> {
peek(): T | undefined
reset(): void
}
class ArrayTokenStream<T> implements TokenStream<T> {
private i = 0
constructor(private arr: T[]) {}
next(): IteratorResult<T> {
if (this.i >= this.arr.length) return { done: true, value: undefined as unknown as T }
return { done: false, value: this.arr[this.i++] }
}
peek() { return this.arr[this.i] }
reset() { this.i = 0 }
}Note how we reuse Iterator
2) Typing the Iterator Result Precisely
IteratorResult
Approach: Provide a method that yields the enriched result, while implementing next for protocol consumers.
type Enriched<T> = { value: T, pos: number }
class EnrichedIter<T> implements Iterator<T> {
private pos = 0
constructor(private source: T[]) {}
next(): IteratorResult<T> {
if (this.pos >= this.source.length) return { done: true, value: undefined as unknown as T }
return { done: false, value: this.source[this.pos++] }
}
nextEnriched(): IteratorResult<Enriched<T>> {
if (this.pos >= this.source.length) return { done: true, value: undefined as unknown as Enriched<T> }
return { done: false, value: { value: this.source[this.pos], pos: this.pos++ } }
}
}This keeps for...of compatibility while offering richer APIs for consumers that opt in.
3) Synchronous Iterator Factories with Strong Types
Factories create iterator instances with correct typing and configuration options. Favor factories over constructors when you need controlled creation or caching.
interface RangeOptions { start?: number; end?: number; step?: number }
function createRangeIterator(opts: RangeOptions = {}): Iterator<number> {
const start = opts.start ?? 0
const end = opts.end ?? Infinity
const step = opts.step ?? 1
let i = start
return {
next(): IteratorResult<number> {
if (i >= end) return { done: true, value: undefined as unknown as number }
const value = i
i += step
return { done: false, value }
}
}
}
const it = createRangeIterator({ start: 0, end: 3 })
console.log(it.next())You can type factory options with generics when you want to return specialized iterator interfaces.
4) Async Iterator Pattern Implementations
When data is produced asynchronously, implement the async iterator pattern. Async iterators are crucial for streaming data and are used with for await...of. See our guide on Typing Async Iterators and Async Iterables in TypeScript — Practical Guide for deeper coverage.
Example: an async line reader that yields file lines.
class AsyncLineReader implements AsyncIterator<string> {
private buffer = ''
constructor(private reader: AsyncIterable<string>) {}
async next(): Promise<IteratorResult<string>> {
const it = this.reader[Symbol.asyncIterator]()
const res = await it.next()
if (res.done) return { done: true, value: undefined as unknown as string }
// simplistic: return raw chunk
return { done: false, value: res.value }
}
}TypeScript can infer the return type. For usage with for await...of, ensure your class implements AsyncIterable as well if you want to be directly iterable.
5) Making Iterators Iterable and AsyncIterable
To make an iterator usable in for...of, expose [Symbol.iterator] returning this. For async, implement [Symbol.asyncIterator]. That often requires type intersection.
class IterableRange implements Iterator<number>, Iterable<number> {
constructor(private start = 0, private end = 3) {}
private i = this.start
next(): IteratorResult<number> {
if (this.i >= this.end) return { done: true, value: undefined as unknown as number }
return { done: false, value: this.i++ }
}
[Symbol.iterator](): Iterator<number> { return this }
}
for (const x of new IterableRange(0, 3)) console.log(x)For async iterables, implement Symbol.asyncIterator and return an AsyncIterator. This enables usage with for await...of, which we cover in more depth in Using for...of and for await...of with Typed Iterables in TypeScript.
6) Preserving Types through Higher-Order Iterator Utilities
Composing iterators with functions such as map, filter, take, and flatMap is common. When typing these higher-order utilities, preserve input and output types. See patterns in Typing Higher-Order Functions in TypeScript — Advanced Scenarios.
Example: typed map over an iterator
function mapIterator<I, O>(it: Iterator<I>, fn: (v: I) => O): Iterator<O> {
return {
next(): IteratorResult<O> {
const r = it.next()
if (r.done) return { done: true, value: undefined as unknown as O }
return { done: false, value: fn(r.value) }
}
}
}This function preserves generics so downstream code gets correct types.
7) Combining Iterators with Memoization and Caching
When iterators are expensive or not rewindable, memoization can help. Typed memoization functions are important so cached values and keys match types. See Typing Memoization Functions in TypeScript for patterns you can adapt.
Pattern: Create a wrapper that caches yielded values and allows replaying.
function memoizeIterator<T>(it: Iterator<T>) {
const cache: T[] = []
let done = false
return {
getCopy() {
let i = 0
return {
next(): IteratorResult<T> {
if (i < cache.length) return { done: false, value: cache[i++] }
if (done) return { done: true, value: undefined as unknown as T }
const r = it.next()
if (r.done) { done = true; return { done: true, value: undefined as unknown as T } }
cache.push(r.value)
i++
return { done: false, value: r.value }
}
}
}
}
}Carefully type the wrapper API so consumers know they are getting a safe replaying iterator.
8) Validation, Assertions and Type Predicates in Iterator APIs
Often you need to validate runtime data coming from an iterator. Use assertion functions and type predicates to refine types and provide safe APIs. For example, when an iterator yields unknown inputs from JSON, assert their shape before consuming. Read our guide on Using Assertion Functions in TypeScript (TS 3.7+) and Using Type Predicates for Filtering Arrays in TypeScript for related techniques.
Example: an assertion used inside an iterator consumer
function assertIsNumber(x: unknown): asserts x is number {
if (typeof x !== 'number') throw new TypeError('expected number')
}
function consumeNumbers(it: Iterator<unknown>) {
let r = it.next()
while (!r.done) {
assertIsNumber(r.value)
console.log(r.value + 1) // typed as number
r = it.next()
}
}9) Interoperability: Wrapping Native Iterables with Custom Behavior
A common pattern is to wrap built-in iterables like arrays or generators to add behavior such as filtering, transformation or resource cleanup. This wrapper should implement Iterable or AsyncIterable when appropriate.
class FilteredIterable<T> implements Iterable<T> {
constructor(private src: Iterable<T>, private pred: (v: T) => boolean) {}
[Symbol.iterator]() {
const it = this.src[Symbol.iterator]()
const pred = this.pred
return {
next(): IteratorResult<T> {
let r = it.next()
while (!r.done && !pred(r.value)) r = it.next()
return r
}
}
}
}
for (const x of new FilteredIterable([1,2,3,4], n => n % 2 === 0)) console.log(x)This preserves compatibility with native for...of while adding domain logic.
Advanced Techniques
Once you have basic iterator implementations, tackle these advanced topics:
-
Variadic and tuple-aware iterator utilities: when mapping over multiple iterators simultaneously, use variadic tuple types to preserve precise result tuples. See our guide on variadic functions in Typing Functions That Accept a Variable Number of Arguments (Tuples and Rest) for patterns.
-
Lazy evaluation with backpressure: for streaming data, build iterators that pause when downstream is slow. In Node streams, you might bridge readable streams and async iterators.
-
Strongly typed pipeline builders: create fluent builders that compose iterator transformations while enforcing sequence rules. This is similar in spirit to builder and factory typing patterns discussed in our series on design patterns such as Typing Builder Pattern Implementations in TypeScript and Typing Factory Pattern Implementations in TypeScript.
-
Preserve 'this' for methods that rely on instance context by leveraging ThisParameterType and OmitThisParameter when exposing callbacks. See Typing Functions That Modify
this(ThisParameterType, OmitThisParameter) for details. -
Use assertion functions to refine iterator element types at runtime and maintain strict type safety. Related reading: Using Assertion Functions in TypeScript (TS 3.7+).
Performance tuning: avoid unnecessary allocations in next calls, reuse objects when safe, and benchmark using realistic workloads.
Best Practices & Common Pitfalls
Dos:
- Implement Symbol.iterator or Symbol.asyncIterator for compatibility with language constructs.
- Keep next light and avoid heavy allocations on each call. Reuse buffers when possible.
- Use generics to make iterators reusable across types and to provide accurate editor hints.
- Provide both protocol-compatible next and richer API methods when you need to return metadata.
- Document side effects clearly, for example whether next consumes resources or can be called multiple times.
Don'ts:
- Do not conflate protocol return values with enriched metadata; keep them separate to avoid breaking for...of consumers.
- Avoid throwing on benign end-of-iteration; use done=true semantics. Reserve throw for exceptional conditions.
- Do not leak internal mutable state returned by value unless documented.
Troubleshooting:
- If for...of type narrowing fails, ensure your type implements Iterable
and that Symbol.iterator returns an Iterator . - If consumers cannot call for await...of, check you implemented Symbol.asyncIterator and the class returns an AsyncIterator
. - When using complex generics across utilities, incremental refactors and small helper types can help the compiler infer intended types.
Real-World Applications
Iterator pattern implementations appear across many domains:
- Parsers and tokenizers: token streams with peek and backtrack methods are classic iterator pattern uses.
- Network streaming: async iterators that yield message frames or chunks.
- Large dataset processing: lazy iterators that read rows from CSV or DB cursors, often combined with caching for retries.
- UI virtual scrolling: an iterator producing visible items based on viewport and data source.
For streaming and async use cases, review Typing Async Iterators and Async Iterables in TypeScript — Practical Guide.
Conclusion & Next Steps
Typing iterator pattern implementations in TypeScript unlocks safer, more maintainable traversal APIs. Start by modeling your domain methods alongside the iterator protocol, use generics to capture value types, and expose iterable symbols for language integration. Move on to async variations, compose utilities, and consider memoization and caching for expensive sources. For further learning, explore our related tutorials on iterator typing and higher-order function patterns.
Next steps:
- Implement a small token stream and ensure it works with for...of
- Build an async file reader that yields parsed records
- Create a typed mapIterator and combine it with a cached wrapper
Enhanced FAQ
Q: How is an iterator pattern implementation different from a generator function? A: A generator is a language feature that returns an iterator object implementing the protocol. An iterator pattern implementation is a manual object that encapsulates traversal logic and often provides additional methods beyond next. Generators are convenient, but manual implementations give you more control for lookahead, resource management, and specialized state. Use generators when the language features match your needs; use a manual pattern when you need extra behavior.
Q: Should I always implement Symbol.iterator on custom iterators? A: Yes, if you want consumers to use for...of directly. Adding Symbol.iterator with a method that returns this, or a new iterator, enables native iteration. For asynchronous data sources, implement Symbol.asyncIterator so consumers can use for await...of.
Q: How do I type an iterator that yields different shapes depending on options? A: Use generics and conditional types. For example, a factory can accept a type parameter or infer it from options. If an option switches the shape, use overloads or discriminated unions for the factory output so the returned iterator type matches the chosen option.
Q: What about performance concerns when implementing next? A: Keep next low-allocation. Avoid creating new objects for each call when possible; reuse a result object or return primitives. However, be mindful of mutability. Benchmark with realistic loads and balance safety and speed. If you do caching, ensure memory usage is acceptable.
Q: Can I mix sync and async iterators in pipelines? A: You can convert between them by writing adapters, but for await...of expects an async iterable. If you mix, wrap sync iterables into async iterables using an async generator that yields their values. See Typing Async Iterators and Async Iterables in TypeScript — Practical Guide for patterns.
Q: How do assertion functions fit into iterator consumption? A: Use assertion functions to validate runtime values yielded by iterators and to refine types for the consumer. This prevents repeated type checks and integrates with TypeScript's type narrowing and control flow analysis. Our guide on Using Assertion Functions in TypeScript (TS 3.7+) is a great reference.
Q: What patterns help when composing multiple iterators at once? A: Use helper utilities like zip and combine that accept multiple iterables and return tuples. Typing such utilities benefits from variadic tuple types; consult Typing Functions That Accept a Variable Number of Arguments (Tuples and Rest) for implementation patterns.
Q: How do I test iterator implementations effectively? A: Write unit tests that exercise typical and edge cases: empty data, single element, large data sets, and resource cleanup. For async iterators, test both happy paths and early termination via return or throw. Consider property-based tests for invariants such as idempotence after reset.
Q: When should I memoize an iterator and what tradeoffs exist? A: Memoize when the source is single-use or expensive, and when you need to replay results. Tradeoffs include increased memory usage. See Typing Memoization Functions in TypeScript for best practices.
Q: How do I ensure my iterator utilities preserve parameter count and types for callbacks? A: When exposing callbacks to consumers, be explicit with types and use helper utilities like ThisParameterType when callbacks rely on instance context. Also review Typing Higher-Order Functions in TypeScript — Advanced Scenarios for patterns that keep function signatures intact.
For further reading, consider diving into related topics like typed caching strategies (Typing Cache Mechanisms: A Practical TypeScript Guide) and advanced iterator-driven designs in libraries. Happy typing and iterating!
