CodeFixesHub
    programming tutorial

    Typing Iterator Pattern Implementations (vs Built-in Iterators)

    Learn to type iterator pattern implementations vs built-in iterators in TypeScript. Practical examples, pitfalls, and optimization tips. Read now.

    article details

    Quick Overview

    TypeScript
    Category
    Sep 11
    Published
    23
    Min Read
    2K
    Words
    article summary

    Learn to type iterator pattern implementations vs built-in iterators in TypeScript. Practical examples, pitfalls, and optimization tips. Read now.

    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:

    1. npm init -y
    2. npm i -D typescript
    3. npx tsc --init
    4. 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 and extra methods that refine state.

    ts
    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 and add domain methods. This keeps compatibility with consumers expecting an iterator while adding rich behavior.

    2) Typing the Iterator Result Precisely

    IteratorResult is the canonical return type of next. But sometimes you need to attach metadata to the result, such as position or a partial error. You can define a custom result type while keeping compatibility with for...of by separating the two concerns.

    Approach: Provide a method that yields the enriched result, while implementing next for protocol consumers.

    ts
    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.

    ts
    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.

    ts
    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.

    ts
    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

    ts
    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.

    ts
    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

    ts
    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.

    ts
    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:

    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!

    article completed

    Great Work!

    You've successfully completed this TypeScript tutorial. Ready to explore more concepts and enhance your development skills?

    share this article

    Found This Helpful?

    Share this TypeScript tutorial with your network and help other developers learn!

    continue learning

    Related Articles

    Discover more programming tutorials and solutions related to this topic.

    No related articles found.

    Try browsing our categories for more content.

    Content Sync Status
    Offline
    Changes: 0
    Last sync: 11:19:58 PM
    Next sync: 60s
    Loading CodeFixesHub...