Dart Advanced Features

Advanced Collections & Iterables

50 min Lesson 10 of 16

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
}
Note: 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]
}
Tip: Use lazy iterables when you only need a subset of results. Avoid calling .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
}
Warning: Never use 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
}
Note: 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
}
Tip: When building frequency maps, the pattern 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:

  • Iterable is the lazy base; List, Set, and their methods return lazy iterables by default.
  • expand is Dart’s flatMap; fold is safer than reduce for empty collections.
  • whereType<T>() filters and casts in one step.
  • Spread (...), collection if, and collection for make declarative collection building easy.
  • SplayTreeMap/SplayTreeSet keep elements sorted; Queue optimizes add/remove from both ends.
  • Combine fold, where, map, and expand for powerful data transformation pipelines.