9 Generic Data Structures
This chapter shows how generics, iterators, and generators work together to decouple independent concerns and maximize reuse in TypeScript. It begins by motivating generics through a reusable identity function and a type-safe alternative to any, then revisits Optional<T> to illustrate how generic types encapsulate behavior independently of the concrete data they carry. Throughout, the emphasis is on separating domain-specific logic from general mechanisms, enabling the compiler to infer types and preserve type safety while reducing duplication.
Building on that foundation, the chapter replaces concrete structures (a number-only binary tree and a string-only linked list) with generic BinaryTreeNode<T> and LinkedListNode<T>, highlighting that data structures have three aspects: the data, the shape (layout), and shape-preserving operations. Traversal is then abstracted via Iterator<T> and Iterable<T>, allowing generic algorithms like print and contains to operate uniformly over different structures. The for...of syntax streamlines consumption of Iterables, and generators provide concise, memory-efficient traversals (yielding values on demand) while supporting multiple traversal strategies over the same structure without coupling algorithms to shapes.
Finally, the chapter extends iteration to infinite streams and pipeline-style processing. Generators can produce unbounded data (for example, random numbers), and can be composed into lazy, demand-driven pipelines with small, reusable stages such as square, take, and drop. Because evaluation occurs only when values are requested, these pipelines scale naturally to continuous or large data flows. The overall result is a modular, type-safe approach where generic data structures define shape, iterators unify traversal, and generators enable concise algorithms and flexible, composable data-processing pipelines.
Generic identity with a type parameter T and two instances: identity<number>() with the concrete type (value: number) => number and identity<Widget[]>() with the concrete type (value: Widget[]) => Widget[].
In-order traversal: recursively go left until we reach the leftmost node, then go to its parent, then to the right node. Next we go back to the parent of the parent, then go to its right node. It’s always left, then when that subtree is all visited parent, then right.
Binary tree example
Linked list example
inOrder() traverses the binary tree in-order and adds all values to a queue. next() dequeues values and returns them during traversal.
BinaryTreeIterator implements binary tree traversal. LinkedListIterator implements linked list traversal. Both implement the Iterator contract. print() and contains() take an Iterator as argument, so we can mix and match the functions with different data structures.
inOrderIterator() is a generator, so it returns an IterableIterator<T>. Like inOrder(), this function recursively traverses the tree but instead of queuing items, it yields them. Calling next() on the returned iterator resumes the generator and yields the next value.
Pipeline and call sequence. take() requests a value from square()’s iterator. square() requests a value from generateRandomNumber()’s iterator. generateRandomNumbers() yields a value to square(). square() yields a value to take().
FAQ
Why use generics for data structures?
Generics let you separate independent concerns: the shape/layout of data from the data’s concrete type. Instead of duplicating nearly identical classes (for example a number-only BinaryTree and a string-only BinaryTree), you write one reusable structure likeclass BinaryTreeNode<T> { value: T; left?: BinaryTreeNode<T>; right?: BinaryTreeNode<T>; }. This reduces code, bugs, and coupling.What’s wrong with implementing identity using any?
Using any disables type checking. For example, square(identity("Hello")) compiles if identity uses any, but fails at runtime. A generic identity preserves type safety: function identity<T>(x: T): T { return x; } now prevents passing a string where a number is expected.How do I write and reuse a safe identity function?
Implement it generically and let TypeScript inferT: function identity<T>(x: T): T { return x; }. You can even use it as a default parameter where a no-op is needed, for example a transform (n: number) => number or a list “pluck” (xs: Widget[]) => Widget[]; the compiler infers the right T each time.What is Optional<T> and why is it useful?
Optional<T> models the presence or absence of a value independently of the value’s type. Its logic (has value? get value) is orthogonal to T, so you can reuse the same class for any type. It’s similar in spirit to using T | undefined, but can encapsulate richer behavior and invariants.What’s the difference between Iterator<T>, Iterable<T>, and IterableIterator<T>?
- Iterator<T>: exposes next(): { done: boolean; value: T }.-
Iterable<T>: exposes [Symbol.iterator](): Iterator<T>, enabling for...of.-
IterableIterator<T>: implements both (what generators return), so you can use it with next() or in for...of.How do generators simplify data-structure traversal?
Generators let you write traversal as a straightforward, resumable function thatyields values on demand. Example (in-order tree): function* inOrderIterator<T>(root: BinaryTreeNode<T>): IterableIterator<T> {
if (root.left) yield* inOrderIterator(root.left);
yield root.value;
if (root.right) yield* inOrderIterator(root.right);
}Compared to manual queues/stacks, this is concise, lazy, and memory-friendly.How can I write traversal-agnostic functions like print or contains?
Target the iterator/iterable interfaces instead of concrete structures: function print<T>(iterable: Iterable<T>): void {
for (const x of iterable) console.log(x);
}
function contains<T>(needle: T, iterable: Iterable<T>): boolean {
for (const x of iterable) if (x === needle) return true;
return false;
}Now the same function works for trees, lists, arrays—anything iterable.Why is IteratorResult<T> considered clunky?
It always returns both done and a value, forcing a dummy value when done === true. A sum type (like Optional<T> or T | undefined) would better express “either a value or no value,” but TypeScript’s standard iterator protocol uses { done, value }.How do processing pipelines work with iterables?
Pipeline steps are generators that take anIterable and yield transformed items, enabling lazy, one-by-one processing—even from infinite sources. Example: function* square(src: Iterable<number>): IterableIterator<number> {
for (const x of src) yield x * x;
}
function* take<T>(src: Iterable<T>, n: number): IterableIterator<T> {
for (const x of src) { if (n-- <= 0) return; yield x; }
}
function* generateRandomNumbers(): IterableIterator<number> {
while (true) yield Math.random();
}
for (const v of take(square(generateRandomNumbers()), 5)) console.log(v);How do I make my own type iterable?
Implement[Symbol.iterator]() and return an iterator (often a generator): class LinkedListNode<T> implements Iterable<T> {
constructor(public value: T, public next?: LinkedListNode<T>) {}
[Symbol.iterator](): Iterator<T> {
return (function* (head: LinkedListNode<T>): IterableIterator<T> {
let cur: LinkedListNode<T> | undefined = head;
while (cur) { yield cur.value; cur = cur.next; }
})(this);
}
}This enables clean for...of traversal.
Programming with Types ebook for free