Advanced Collections & Iterables
Iterable vs List vs Set vs Map
Dart’s collection hierarchy is built on the Iterable interface. Understanding the differences between the core collection types is essential for writing efficient, expressive code.
Collection Type Overview
void main() {
// Iterable — the base interface, lazily evaluated
Iterable<int> lazyRange = Iterable.generate(5, (i) => i * 10);
print(lazyRange); // (0, 10, 20, 30, 40)
// List — ordered, indexed, allows duplicates
List<int> numbers = [1, 2, 3, 2, 1];
print(numbers[2]); // 3 (index access)
// Set — unordered (insertion-ordered in Dart), no duplicates
Set<int> unique = {1, 2, 3, 2, 1};
print(unique); // {1, 2, 3}
// Map — key-value pairs, keys are unique
Map<String, int> ages = {'Alice': 30, 'Bob': 25};
print(ages['Alice']); // 30
}
List and Set both implement Iterable, so any method that works on Iterable works on both. Map does not implement Iterable directly, but map.keys, map.values, and map.entries are all Iterables.Lazy Evaluation with Iterables
One of the most important features of Iterable is lazy evaluation. Many operations like map, where, expand, and take return lazy iterables that only compute values when iterated.
Lazy vs Eager Evaluation
void main() {
final numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
// Lazy: nothing happens until we iterate
final lazyResult = numbers
.where((n) {
print(' Checking $n');
return n.isEven;
})
.map((n) {
print(' Mapping $n');
return n * n;
});
print('--- Taking first 2 ---');
// Only processes elements until it finds 2 matches
final first2 = lazyResult.take(2).toList();
print(first2); // [4, 16]
// Output shows it only checked 1,2,3,4 — not the entire list!
print('\n--- Eager: toList() forces full evaluation ---');
final eagerResult = numbers.where((n) => n.isEven).map((n) => n * n).toList();
print(eagerResult); // [4, 16, 36, 64, 100]
}
.toList() until you actually need random access or need to iterate multiple times. This can save significant computation on large datasets.The expand Method
The expand method transforms each element into zero or more elements, flattening the result into a single iterable. It is Dart’s equivalent of flatMap in other languages.
Using expand (flatMap)
void main() {
final sentences = ['Hello world', 'Dart is great', 'Flutter rocks'];
// Split each sentence into words and flatten
final words = sentences.expand((s) => s.split(' '));
print(words.toList());
// [Hello, world, Dart, is, great, Flutter, rocks]
// Duplicate each element
final doubled = [1, 2, 3].expand((n) => [n, n]);
print(doubled.toList()); // [1, 1, 2, 2, 3, 3]
// Filter and transform in one step
final matrix = [[1, 2, 3], [4, 5, 6], [7, 8, 9]];
final evenNumbers = matrix.expand((row) => row.where((n) => n.isEven));
print(evenNumbers.toList()); // [2, 4, 6, 8]
}
fold and reduce
fold and reduce are aggregation methods that combine all elements into a single value. The key difference is that fold takes an initial value, while reduce uses the first element as the starting point.
fold vs reduce
void main() {
final numbers = [1, 2, 3, 4, 5];
// fold: starts with an initial value
final sum = numbers.fold<int>(0, (acc, n) => acc + n);
print('Sum: $sum'); // Sum: 15
final product = numbers.fold<int>(1, (acc, n) => acc * n);
print('Product: $product'); // Product: 120
// reduce: uses first element as initial value
final max = numbers.reduce((a, b) => a > b ? a : b);
print('Max: $max'); // Max: 5
// fold can change the type; reduce cannot
final csv = numbers.fold<String>('', (acc, n) =>
acc.isEmpty ? '$n' : '$acc, $n');
print('CSV: $csv'); // CSV: 1, 2, 3, 4, 5
// reduce on empty list throws StateError!
// [].reduce((a, b) => a + b); // ERROR: No element
// fold on empty list returns the initial value
final emptySum = <int>[].fold<int>(0, (acc, n) => acc + n);
print('Empty sum: $emptySum'); // Empty sum: 0
}
reduce on a collection that might be empty — it throws a StateError. Always prefer fold when there is any chance the collection could be empty, as it safely returns the initial value.whereType and Type Filtering
The whereType<T>() method filters elements by their runtime type, returning only elements that are instances of T. This is both a filter and a cast in one step.
Filtering by Type
void main() {
final mixed = [1, 'hello', 2.5, true, 42, 'world', 3.14, false];
// Get only integers
final ints = mixed.whereType<int>();
print(ints.toList()); // [1, 42]
// Get only strings
final strings = mixed.whereType<String>();
print(strings.toList()); // [hello, world]
// Get only numbers (int and double)
final nums = mixed.whereType<num>();
print(nums.toList()); // [1, 2.5, 42, 3.14]
// This is much cleaner than:
final intsOldWay = mixed.where((e) => e is int).cast<int>();
}
followedBy and Concatenation
The followedBy method lazily concatenates two iterables without creating a new list. This is memory-efficient for large collections.
Lazy Concatenation with followedBy
void main() {
final first = [1, 2, 3];
final second = [4, 5, 6];
final third = [7, 8, 9];
// Lazy concatenation — no new list is created
final combined = first.followedBy(second).followedBy(third);
print(combined.toList()); // [1, 2, 3, 4, 5, 6, 7, 8, 9]
// Compare with spread operator (creates a new list immediately)
final eager = [...first, ...second, ...third];
print(eager); // [1, 2, 3, 4, 5, 6, 7, 8, 9]
// followedBy is better when you only need to iterate once
// Spread is better when you need a concrete List
}
Spread Operator and Collection if/for
Dart’s collection literals support the spread operator (... and ...?), collection if, and collection for for building collections declaratively.
Spread, Collection if, and Collection for
void main() {
final base = [1, 2, 3];
List<int>? maybeNull;
final extra = [7, 8, 9];
// Spread operator
final combined = [...base, 4, 5, 6, ...extra];
print(combined); // [1, 2, 3, 4, 5, 6, 7, 8, 9]
// Null-aware spread
final safe = [...base, ...?maybeNull, ...extra];
print(safe); // [1, 2, 3, 7, 8, 9]
// Collection if
bool isAdmin = true;
final menu = [
'Home',
'Profile',
if (isAdmin) 'Admin Panel',
'Settings',
];
print(menu); // [Home, Profile, Admin Panel, Settings]
// Collection for
final squares = [
for (var i = 1; i <= 5; i++) i * i,
];
print(squares); // [1, 4, 9, 16, 25]
// Combining all three
final dashboard = [
'Overview',
if (isAdmin) ...['Users', 'Roles', 'Logs'],
for (var section in ['Reports', 'Analytics'])
section.toUpperCase(),
];
print(dashboard);
// [Overview, Users, Roles, Logs, REPORTS, ANALYTICS]
}
Unmodifiable Collections
Dart provides several ways to create collections that cannot be modified after creation. This is important for immutability and API safety.
Creating Unmodifiable Collections
void main() {
// Method 1: List.unmodifiable (creates an unmodifiable copy)
final source = [3, 1, 4, 1, 5];
final frozen = List.unmodifiable(source);
// frozen.add(9); // ERROR: Unsupported operation
// frozen[0] = 0; // ERROR: Unsupported operation
source.add(9); // OK — source is still mutable
print(frozen); // [3, 1, 4, 1, 5] (unaffected by source changes)
// Method 2: UnmodifiableListView (creates an unmodifiable VIEW)
import 'dart:collection';
final view = UnmodifiableListView(source);
// view.add(1); // ERROR
source.add(2);
print(view); // [3, 1, 4, 1, 5, 9, 2] (reflects source changes!)
// Method 3: const literals (compile-time immutable)
const immutable = [1, 2, 3];
// immutable.add(4); // ERROR
// For Maps
final frozenMap = Map.unmodifiable({'a': 1, 'b': 2});
// frozenMap['c'] = 3; // ERROR
// For Sets
final frozenSet = Set.unmodifiable({1, 2, 3});
// frozenSet.add(4); // ERROR
}
List.unmodifiable creates a copy that is frozen in time. UnmodifiableListView creates a view that reflects changes to the original but prevents modifications through the view. Choose based on whether you need isolation from the source or just read-only access.SplayTreeMap and SplayTreeSet
For collections that need to maintain a sorted order, Dart provides SplayTreeMap and SplayTreeSet from dart:collection. They use a self-balancing binary tree internally.
Sorted Collections with SplayTree
import 'dart:collection';
void main() {
// SplayTreeSet — always sorted
final sortedNumbers = SplayTreeSet<int>();
sortedNumbers.addAll([5, 2, 8, 1, 9, 3]);
print(sortedNumbers); // {1, 2, 3, 5, 8, 9}
// Custom comparator for reverse order
final descending = SplayTreeSet<int>((a, b) => b.compareTo(a));
descending.addAll([5, 2, 8, 1, 9, 3]);
print(descending); // {9, 8, 5, 3, 2, 1}
// SplayTreeMap — keys are always sorted
final sortedMap = SplayTreeMap<String, int>();
sortedMap['banana'] = 2;
sortedMap['apple'] = 5;
sortedMap['cherry'] = 3;
print(sortedMap); // {apple: 5, banana: 2, cherry: 3}
// Custom sorting for complex objects
final byLength = SplayTreeSet<String>(
(a, b) => a.length != b.length
? a.length.compareTo(b.length)
: a.compareTo(b),
);
byLength.addAll(['fig', 'apple', 'kiwi', 'banana', 'date']);
print(byLength); // {fig, date, kiwi, apple, banana}
}
Queue from dart:collection
A Queue is a collection optimized for adding and removing elements from both ends. It is ideal for FIFO (first-in, first-out) operations.
Using Queue
import 'dart:collection';
void main() {
final queue = Queue<String>();
// Add to the back (enqueue)
queue.add('First');
queue.add('Second');
queue.add('Third');
print(queue); // {First, Second, Third}
// Add to the front
queue.addFirst('Zero');
print(queue); // {Zero, First, Second, Third}
// Remove from the front (dequeue) — O(1)
final first = queue.removeFirst();
print('Removed: $first'); // Removed: Zero
// Remove from the back — O(1)
final last = queue.removeLast();
print('Removed: $last'); // Removed: Third
print(queue); // {First, Second}
// Peek without removing
print('First: ${queue.first}'); // First: First
print('Last: ${queue.last}'); // Last: Second
}
Practical Example: Data Grouping
Grouping data is a very common operation. Here is how to build a generic groupBy function using fold.
Grouping Data with fold
/// Groups elements by a key extracted from each element.
Map<K, List<V>> groupBy<K, V>(Iterable<V> items, K Function(V) keyFn) {
return items.fold<Map<K, List<V>>>({}, (map, item) {
final key = keyFn(item);
(map[key] ??= []).add(item);
return map;
});
}
void main() {
final people = [
{'name': 'Alice', 'dept': 'Engineering'},
{'name': 'Bob', 'dept': 'Marketing'},
{'name': 'Charlie', 'dept': 'Engineering'},
{'name': 'Diana', 'dept': 'Marketing'},
{'name': 'Eve', 'dept': 'Design'},
];
final byDept = groupBy(people, (p) => p['dept']!);
byDept.forEach((dept, members) {
print('$dept: ${members.map((m) => m['name']).join(', ')}');
});
// Engineering: Alice, Charlie
// Marketing: Bob, Diana
// Design: Eve
}
Practical Example: Transformation Chains
Method chaining on collections creates powerful, readable data transformation pipelines.
Data Transformation Pipeline
class Product {
final String name;
final String category;
final double price;
final int stock;
Product(this.name, this.category, this.price, this.stock);
}
void main() {
final products = [
Product('Laptop', 'Electronics', 999.99, 50),
Product('Phone', 'Electronics', 699.99, 200),
Product('Shirt', 'Clothing', 29.99, 500),
Product('Headphones', 'Electronics', 149.99, 0),
Product('Jeans', 'Clothing', 59.99, 150),
Product('Tablet', 'Electronics', 449.99, 75),
];
// Pipeline: in-stock electronics, sorted by price, formatted
final result = products
.where((p) => p.category == 'Electronics')
.where((p) => p.stock > 0)
.toList()
..sort((a, b) => a.price.compareTo(b.price))
..forEach((p) => print('${p.name}: \$${p.price} (${p.stock} in stock)'));
// Phone: $699.99 (200 in stock)
// Tablet: $449.99 (75 in stock) — wait, sorted ascending!
// Actually: Tablet: $449.99, Phone: $699.99, Laptop: $999.99
// Total value of in-stock electronics
final totalValue = products
.where((p) => p.category == 'Electronics' && p.stock > 0)
.fold<double>(0.0, (sum, p) => sum + p.price * p.stock);
print('Total inventory value: \$${totalValue.toStringAsFixed(2)}');
}
Practical Example: Frequency Counting
Counting the frequency of elements is a common task that combines maps and fold elegantly.
Frequency Counter
/// Counts occurrences of each element.
Map<T, int> frequency<T>(Iterable<T> items) {
return items.fold<Map<T, int>>({}, (counts, item) {
counts[item] = (counts[item] ?? 0) + 1;
return counts;
});
}
/// Returns the top N most frequent elements.
List<MapEntry<T, int>> topN<T>(Map<T, int> freq, int n) {
return (freq.entries.toList()..sort((a, b) => b.value.compareTo(a.value)))
.take(n)
.toList();
}
void main() {
final words = 'the cat sat on the mat the cat ate the rat'.split(' ');
final wordFreq = frequency(words);
print(wordFreq);
// {the: 4, cat: 2, sat: 1, on: 1, mat: 1, ate: 1, rat: 1}
final top3 = topN(wordFreq, 3);
for (final entry in top3) {
print('${entry.key}: ${entry.value} times');
}
// the: 4 times
// cat: 2 times
// sat: 1 times
}
counts[item] = (counts[item] ?? 0) + 1 is idiomatic Dart. The null-aware operator ?? handles the case where the key does not yet exist in the map, defaulting to 0 before incrementing.Summary
Dart’s collection library provides rich, composable tools for data manipulation. Key takeaways:
Iterableis the lazy base;List,Set, and their methods return lazy iterables by default.expandis Dart’s flatMap;foldis safer thanreducefor empty collections.whereType<T>()filters and casts in one step.- Spread (
...), collection if, and collection for make declarative collection building easy. SplayTreeMap/SplayTreeSetkeep elements sorted;Queueoptimizes add/remove from both ends.- Combine
fold,where,map, andexpandfor powerful data transformation pipelines.