Creating Custom Iterators and Iterables in JavaScript

As a Senior Frontend Developer, you’re likely already familiar with JavaScript’s iteration mechanisms, such as for...of
, forEach
, and the spread operator. These tools rely on the iterable protocol, a fundamental concept that allows objects to define custom iteration behavior. But what if you need to iterate over a custom data structure in a specific way? That’s where creating custom iterators and iterables comes into play. Let’s dive into what iterators and iterables are, and how to implement them using JavaScript and TypeScript.
What Are Iterables and Iterators?
An iterable is an object that defines its iteration behavior, typically using a Symbol.iterator
method. This method returns an iterator, which is an object responsible for producing a sequence of values, one at a time.
- Iterable Protocol: An object is iterable if it has a
Symbol.iterator
method that returns an iterator. - Iterator Protocol: An iterator is an object with a
next
method that returns an object containing two properties: value
: The current value in the sequence.done
: A boolean indicating whether the iteration is complete.
Why Create Custom Iterators and Iterables?
JavaScript’s built-in objects like arrays, maps, and sets are iterable by default. However, for custom data structures or specific iteration requirements, defining custom iterables can provide:
- Flexibility: Tailored iteration logic for your data structure.
- Encapsulation: Keep iteration logic encapsulated within the object itself.
- Improved Readability: Simplify code by abstracting complex iteration logic.
Implementing a Custom Iterator
Let’s start with a simple example: creating a custom iterator for a range of numbers.
Example 1: Basic Custom Iterator
function createRangeIterator(start, end) {
let current = start;
return {
next() {
if (current <= end) {
return { value: current++, done: false };
}
return { value: undefined, done: true };
},
};
}
const range = createRangeIterator(1, 5);
console.log(range.next()); // { value: 1, done: false }
console.log(range.next()); // { value: 2, done: false }
console.log(range.next()); // { value: 3, done: false }
Creating an Iterable Object
To make an object iterable, we implement the Symbol.iterator
method, which must return an iterator.
Example 2: Adding Iterable Protocol
function createRange(start, end) {
return {
[Symbol.iterator]() {
return createRangeIterator(start, end);
},
};
}
const range = createRange(1, 5);
for (const num of range) {
console.log(num); // 1, 2, 3, 4, 5
}
Using Generators for Simplicity
Generators simplify creating iterators by automatically handling the state and next
method.
Example 3: Custom Iterable with Generators
function createRange(start, end) {
return {
*[Symbol.iterator]() {
for (let i = start; i <= end; i++) {
yield i;
}
},
};
}
const range = createRange(1, 5);
console.log([...range]); // [1, 2, 3, 4, 5]
Custom Iterables in TypeScript
TypeScript enhances the developer experience by providing type safety and autocompletion for iterables and iterators.
Example 4: TypeScript Iterable Example
type RangeIteratorResult = { value: number; done: boolean };
function createRangeIterator(start: number, end: number): Iterator<number> {
let current = start;
return {
next(): RangeIteratorResult {
if (current <= end) {
return { value: current++, done: false };
}
return { value: undefined as any, done: true };
},
};
}
function createRange(start: number, end: number): Iterable<number> {
return {
[Symbol.iterator](): Iterator<number> {
return createRangeIterator(start, end);
},
};
}
const range = createRange(1, 5);
for (const num of range) {
console.log(num); // 1, 2, 3, 4, 5
}
Advanced Use Case: Bidirectional Iterator
Let’s build a custom bidirectional iterator to iterate both forward and backward.
Example 5: Bidirectional Iterator
function createBidirectionalRange(start: number, end: number): Iterable<number> {
let current = start;
return {
[Symbol.iterator]() {
return {
next(): IteratorResult<number> {
if (current <= end) {
return { value: current++, done: false };
}
return { value: undefined as any, done: true };
},
previous(): IteratorResult<number> {
if (current > start) {
return { value: --current, done: false };
}
return { value: undefined as any, done: true };
},
};
},
};
}
Conclusion
Creating custom iterators and iterables allows you to define how your data structures are traversed. By adhering to the iterable and iterator protocols, you can create intuitive, reusable, and encapsulated iteration logic. Whether you’re working with JavaScript or TypeScript, these tools enhance your ability to build robust and maintainable applications. Leverage custom iterables to simplify complex iteration scenarios and provide a seamless experience for future developers using your code.