Explore the Iterator Pattern in TypeScript, leveraging iterables, iterators, and async iterators for type-safe and efficient data traversal.
The Iterator Pattern is a fundamental design pattern that allows sequential access to the elements of an aggregate object without exposing its underlying representation. In TypeScript, this pattern is enhanced by the language’s strong type system, which provides type safety and compile-time error checking, making it an ideal choice for implementing iterators in complex applications.
TypeScript provides built-in support for iterables and iterators, aligning with the ECMAScript 2015 (ES6) standards. At the heart of this support are the Iterable<T>
and Iterator<T>
interfaces, which define the contract for objects that can be iterated over.
Iterator<T>
and Iterable<T>
InterfacesIn TypeScript, an Iterator<T>
is an object that adheres to the following interface:
interface Iterator<T> {
next(value?: any): IteratorResult<T>;
}
interface IteratorResult<T> {
done: boolean;
value: T;
}
The next
method returns an IteratorResult<T>
, which contains a value
of type T
and a done
boolean indicating whether the iteration is complete.
An Iterable<T>
is an object that implements the Symbol.iterator
method, returning an Iterator<T>
:
interface Iterable<T> {
[Symbol.iterator](): Iterator<T>;
}
These interfaces allow TypeScript to enforce type safety when iterating over collections.
Let’s implement a custom iterator for a simple collection, such as a range of numbers. This example will demonstrate the use of Iterator<T>
and Iterable<T>
interfaces in TypeScript.
class NumberRange implements Iterable<number> {
constructor(private start: number, private end: number) {}
[Symbol.iterator](): Iterator<number> {
let current = this.start;
const end = this.end;
return {
next(): IteratorResult<number> {
if (current <= end) {
return { value: current++, done: false };
} else {
return { value: null, done: true };
}
}
};
}
}
// Usage
const range = new NumberRange(1, 5);
for (const num of range) {
console.log(num); // Outputs: 1, 2, 3, 4, 5
}
In this example, NumberRange
implements the Iterable<number>
interface, allowing it to be used in a for...of
loop. The iterator logic is encapsulated within the next
method.
TypeScript’s type system ensures that the implementation of iterators adheres to the defined interfaces. This type safety helps catch errors at compile time, reducing runtime issues. For instance, if you attempt to return a non-number value in the NumberRange
iterator, TypeScript will raise a type error.
Symbol.asyncIterator
In modern applications, data sources are often asynchronous, requiring iterators that can handle asynchronous operations. TypeScript supports asynchronous iterators using the Symbol.asyncIterator
symbol.
Consider a scenario where you fetch data from an API in chunks. An async iterator can be used to handle this asynchronous data retrieval:
class AsyncNumberRange implements AsyncIterable<number> {
constructor(private start: number, private end: number) {}
async *[Symbol.asyncIterator](): AsyncIterator<number> {
for (let i = this.start; i <= this.end; i++) {
await new Promise(resolve => setTimeout(resolve, 100)); // Simulate async operation
yield i;
}
}
}
// Usage
(async () => {
const asyncRange = new AsyncNumberRange(1, 5);
for await (const num of asyncRange) {
console.log(num); // Outputs: 1, 2, 3, 4, 5 with delays
}
})();
This example uses an async
generator function to yield values asynchronously, leveraging TypeScript’s AsyncIterable<T>
interface.
TypeScript’s iterators can be seamlessly integrated with collections like Maps and Sets, which are inherently iterable.
Consider the following example where we iterate over a Map
:
const map = new Map<string, number>([
['one', 1],
['two', 2],
['three', 3]
]);
for (const [key, value] of map) {
console.log(`${key}: ${value}`); // Outputs: one: 1, two: 2, three: 3
}
Maps and Sets in TypeScript implement the Iterable
interface, allowing them to be used in for...of
loops.
Generators in TypeScript can be used to create iterators with specific return types, enhancing type safety and clarity.
Here’s an example of a generator function that yields numbers:
function* numberGenerator(): Generator<number, void, unknown> {
yield 1;
yield 2;
yield 3;
}
const gen = numberGenerator();
for (const num of gen) {
console.log(num); // Outputs: 1, 2, 3
}
The Generator<number, void, unknown>
type annotation specifies that the generator yields numbers, returns nothing, and accepts unknown values for next
.
Generics in TypeScript allow iterators to be flexible and reusable across different data types.
Let’s create a generic iterator for an array:
class ArrayIterator<T> implements Iterable<T> {
constructor(private items: T[]) {}
[Symbol.iterator](): Iterator<T> {
let index = 0;
const items = this.items;
return {
next(): IteratorResult<T> {
if (index < items.length) {
return { value: items[index++], done: false };
} else {
return { value: null, done: true };
}
}
};
}
}
// Usage
const stringIterator = new ArrayIterator<string>(['a', 'b', 'c']);
for (const item of stringIterator) {
console.log(item); // Outputs: a, b, c
}
The ArrayIterator
class is generic, allowing it to iterate over arrays of any type.
Iterating over complex data structures, such as trees or graphs, can be achieved by implementing custom iterators.
Consider a simple binary tree:
class TreeNode<T> {
constructor(public value: T, public left: TreeNode<T> | null = null, public right: TreeNode<T> | null = null) {}
}
class TreeIterator<T> implements Iterable<T> {
constructor(private root: TreeNode<T> | null) {}
*[Symbol.iterator](): Iterator<T> {
function* inOrderTraversal(node: TreeNode<T> | null): Generator<T> {
if (node) {
yield* inOrderTraversal(node.left);
yield node.value;
yield* inOrderTraversal(node.right);
}
}
yield* inOrderTraversal(this.root);
}
}
// Usage
const root = new TreeNode<number>(1, new TreeNode(2), new TreeNode(3));
const treeIterator = new TreeIterator(root);
for (const value of treeIterator) {
console.log(value); // Outputs: 2, 1, 3
}
This example uses a generator function to perform an in-order traversal of a binary tree.
When dealing with optional elements or nullable types, TypeScript’s type system can help manage potential null values.
Consider a scenario where some elements may be null:
class NullableIterator<T> implements Iterable<T | null> {
constructor(private items: (T | null)[]) {}
[Symbol.iterator](): Iterator<T | null> {
let index = 0;
const items = this.items;
return {
next(): IteratorResult<T | null> {
if (index < items.length) {
return { value: items[index++], done: false };
} else {
return { value: null, done: true };
}
}
};
}
}
// Usage
const nullableIterator = new NullableIterator<number>([1, null, 3]);
for (const item of nullableIterator) {
console.log(item); // Outputs: 1, null, 3
}
This iterator can handle elements that may be null, providing flexibility in data processing.
Proper documentation of iterators and their expected behavior is crucial for maintainability and usability.
Iterators can only be consumed once, which may lead to issues if not managed correctly.
Efficient iterators can significantly impact application performance, especially when dealing with large datasets.
Combinatoric iterators can generate permutations, combinations, or other mathematical sequences.
Here’s a simple permutation generator:
function* permutations<T>(arr: T[], n = arr.length): Generator<T[]> {
if (n <= 1) {
yield arr.slice();
} else {
for (let i = 0; i < n; i++) {
yield* permutations(arr, n - 1);
const j = n % 2 ? 0 : i;
[arr[n - 1], arr[j]] = [arr[j], arr[n - 1]];
}
}
}
// Usage
const permGen = permutations([1, 2, 3]);
for (const perm of permGen) {
console.log(perm); // Outputs all permutations of [1, 2, 3]
}
This generator function produces all permutations of an array, showcasing the power of combinatoric iterators.
The Iterator Pattern in TypeScript offers a robust framework for iterating over collections with type safety and flexibility. By leveraging TypeScript’s features, such as generics and async iterators, developers can create efficient and reusable iterators for a wide range of applications. Proper documentation, error handling, and performance optimization are key to maximizing the benefits of iterators in TypeScript.