Articles & knowledge
Choosing Dart Collections: From Data Contract to Testable Pipeline
A practical guide to choosing List, Set, and Map, reasoning about lazy Iterable pipelines, and validating the result instead of relying on generic rules.
Scope
This article is not an API inventory for List, Set, and Map. Its purpose is to turn domain requirements into a collection choice that can be explained and tested. The complete program below targets Dart 3 and was executed with Dart SDK 3.12.1.
Prerequisites:
- install the Dart SDK;
- understand generic types such as
List<String>; - save the example to a file and run
dart run example.dart.
Start with the contract, not the collection name
Dart directly supports lists, sets, and maps. A list is an ordered sequence, a set represents unique elements, and a map associates a unique key with a value. That leads to concrete questions:
| Question | Initial choice | Reason |
|---|---|---|
| Are input order and duplicates part of the meaning? | List<T> | It preserves elements in an indexable sequence. |
| Is the requirement unique membership? | Set<T> | It represents each element once according to the type's equality. |
| Is a value retrieved by a unique key? | Map<K, V> | It separates an item's identity from its associated data. |
“Faster” is not a sufficient generic requirement. If report order matters, changing the data too early into a structure whose contract does not express the needed order creates a hidden dependency. Preserve the meaning first, then measure performance with realistic data.
An Iterable can be a pipeline, not a materialized result
Operations such as where and map compose over Iterable. This makes the processing stages explicit:
1. validate input;
2. filter;
3. transform;
4. aggregate or materialize the result.
Call toList() when a consumer needs an indexable snapshot or when the pipeline should not be evaluated again. Effective Dart recommends toList() when copying an iterable while preserving its element type, and reserves List.from() for intentional type changes.
Executable example: summarize completed orders
The example separates the original order list, the set of accepted identifiers, and the totals map:
typedef Order = ({String id, String customer, int amount, bool completed});
Map<String, int> totalsByCustomer(Iterable<Order> orders) {
final acceptedIds = <String>{};
final totals = <String, int>{};
for (final order in orders.where((order) => order.completed)) {
if (order.amount < 0) {
throw ArgumentError.value(order.amount, 'amount', 'must not be negative');
}
if (!acceptedIds.add(order.id)) {
continue;
}
totals.update(
order.customer,
(current) => current + order.amount,
ifAbsent: () => order.amount,
);
}
return Map.unmodifiable(totals);
}
void main() {
final orders = <Order>[
(id: 'o-1', customer: 'A', amount: 40, completed: true),
(id: 'o-2', customer: 'B', amount: 15, completed: false),
(id: 'o-1', customer: 'A', amount: 40, completed: true),
(id: 'o-3', customer: 'A', amount: 10, completed: true),
];
final result = totalsByCustomer(orders);
assert(result.length == 1);
assert(result['A'] == 50);
print(result); // {A: 50}
}Each structure has a separate responsibility:
List<Order>preserves the input sample, including the duplicate and incomplete order;Set<String>makes duplicate acceptance explicit;Map<String, int>represents aggregation by customer;Map.unmodifiableprevents callers from mutating the returned result.
Edge cases to test
Before production use, specify and test:
- empty input;
- all orders incomplete;
- the same identifier attached to a different customer;
- a negative amount, rejected here with
ArgumentError; - a total that exceeds a business limit even if
intcan represent it; - deterministic output ordering when a report requires it.
If duplicate identity is composite, avoid an undocumented joined string such as '$customer:$id'. Use a record or value type whose equality expresses the contract.
Common mistakes
Creating an untyped empty set
The literal {} creates a Map<dynamic, dynamic>, not a set. Write <String>{} for an empty string set.
Checking emptiness through length
Use isEmpty or isNotEmpty. It communicates intent and avoids requiring an arbitrary Iterable to calculate a length that is not needed.
Using forEach when control flow matters
Effective Dart prefers a for-in loop over Iterable.forEach() with a function literal. A loop is clearer when the algorithm needs continue, break, or scoped exception handling.
Hiding a type problem with cast()
Prefer creating the collection with the correct type or using whereType<T>() for mixed input. cast() can defer a type error until an element is accessed, far from the source of the data.
Verification workflow
1. Write the contract: are order, duplicates, and keys meaningful?
2. Build a small example with a success, failure, and boundary case.
3. Run dart analyze and dart run.
4. Benchmark only when size or latency is a measured problem.
5. Return an unmodifiable result when immutability is part of the function boundary.
Limitations
This article does not compare specialized dart:collection structures, measure memory or time, or claim that one structure is universally superior. Results depend on data size, access patterns, and the platform version.
References
Update record
- 2026-10-09: Publication approved by the site owner. The program was analyzed and executed again on Dart 3.12.1, with five passing boundary checks. This verifies the example, not independent language review or the complete book manuscript.
- 2026-10-04: Rebuilt as an applied guide with a program executed on Dart 3.12.1, edge cases, limitations, and primary references. Technical and language review are still required before publication.