Collections in Dart
This is Part 6 of the Dart Fundamentals series. We've covered types (Part 4) and null safety (Part 5) — now we get to the data structures you'll use in basically every program: List, Set, and Map.
The good news: Dart's collection literals are some of the nicest in any language, thanks to spreads and "collection-if/for." By the end of this post you'll know which collection to reach for and how to build them elegantly.
The three collections at a glance
| Collection | Keeps order? | Allows duplicates? | Looked up by | Literal |
| ---------- | ------------ | ------------------ | --------------- | ----------- |
| List | ✅ Yes | ✅ Yes | index (0,1,2) | [a, b, c] |
| Set | ❌ No* | ❌ No (unique) | value | {a, b, c} |
| Map | ✅ Insertion | keys unique | key | {k: v} |
*Dart's default Set actually preserves insertion order, but conceptually you should treat a set as unordered.
Pick by the question you're answering:
- "What's the 3rd item?" → List (ordered, indexed).
- "Have I seen this before / give me only unique values" → Set.
- "What's the value for this key?" → Map.
Let's take them one at a time.
List — ordered and indexed
A List is an ordered group of items you access by position. This is the workhorse:
var fruits = ['apple', 'banana', 'cherry'];
print(fruits[0]); // apple (indexes start at 0)
print(fruits.length); // 3
print(fruits.first); // apple
print(fruits.last); // cherry
Add, insert, and remove:
fruits.add('date'); // → [apple, banana, cherry, date]
fruits.insert(0, 'avocado'); // → [avocado, apple, banana, cherry, date]
fruits.remove('banana'); // removes by value
fruits.removeAt(0); // removes by index
Typed lists and inference
From Part 4, remember that an empty literal can't infer its element type — annotate it:
var scores = <int>[]; // List<int>, not List<dynamic>
scores.add(10);
// scores.add('x'); // ❌ caught at compile time
The everyday transforming methods
You'll lean on these constantly — they each return a new iterable, leaving the original alone:
var nums = [1, 2, 3, 4, 5];
nums.map((n) => n * 2); // (2, 4, 6, 8, 10)
nums.where((n) => n.isEven); // (2, 4)
nums.firstWhere((n) => n > 3); // 4
nums.any((n) => n > 4); // true
nums.every((n) => n > 0); // true
nums.fold(0, (sum, n) => sum + n); // 15
nums.reduce((a, b) => a + b); // 15
(map/where return a lazy Iterable; call .toList() when you need a concrete List.)
Set — unique values, fast membership
A Set holds unique values and is built for one question above all: "is this in here?" Checking membership in a set is far faster than scanning a list.
var visited = <String>{'home', 'about', 'home'};
print(visited); // {home, about} — the duplicate 'home' is dropped
Notice duplicates are silently ignored — that's the defining feature.
visited.add('contact'); // {home, about, contact}
visited.add('home'); // no effect — already present
print(visited.contains('about')); // true — and this is FAST
⚠️ The empty-set gotcha (read this twice)
Both sets and maps use curly braces. So what is {}? Dart resolves the ambiguity in favour of Map:
var a = {}; // ❌ this is a Map<dynamic, dynamic>, NOT a set!
var b = <String>{}; // ✅ THIS is an empty Set<String>
If you want an empty set, you must give it a type: <String>{}. Forget the type annotation and you'll get a map, which leads to baffling errors. This bites everyone once.
Set algebra
Sets give you the classic mathematical operations — genuinely handy:
var a = {1, 2, 3, 4};
var b = {3, 4, 5, 6};
a.union(b); // {1, 2, 3, 4, 5, 6} — everything
a.intersection(b); // {3, 4} — in both
a.difference(b); // {1, 2} — in a but not b
Deduplicating a list is a one-liner with this:
var withDupes = [1, 2, 2, 3, 3, 3];
var unique = withDupes.toSet().toList(); // [1, 2, 3]
Map — key/value pairs
A Map associates keys with values — a dictionary/lookup table. Keys are unique; values can repeat.
var ages = {
'Asha': 30,
'Ben': 25,
'Cara': 30, // value can repeat...
};
print(ages['Asha']); // 30
print(ages['Nobody']); // null — missing key returns null, never crashes
Note that returned type: looking up a key gives a nullable value (int? here), because the key might not exist. That's null safety from Part 5 protecting you again.
int age = ages['Asha'] ?? 0; // supply a default for the maybe-missing key
Add, update, check, remove:
ages['Dan'] = 40; // add or overwrite
ages.containsKey('Ben'); // true
ages.remove('Ben'); // removes the entry
ages.putIfAbsent('Eve', () => 22); // add only if missing
Iterating a map:
ages.forEach((name, age) => print('$name is $age'));
for (final entry in ages.entries) {
print('${entry.key} → ${entry.value}');
}
ages.keys; // the names
ages.values; // the ages
The fun part: building collections elegantly
This is where Dart pulls ahead of most languages. Three features turn clunky build-up code into clean, declarative literals.
Spread operator ... — pour one collection into another
var base = [1, 2, 3];
var more = [0, ...base, 4]; // [0, 1, 2, 3, 4]
No addAll loops — just splat the elements right into the new literal. Works for sets and maps too:
var defaults = {'theme': 'light', 'lang': 'en'};
var settings = {...defaults, 'theme': 'dark'}; // later key wins → theme is 'dark'
Null-aware spread ...? — spread only if not null
If the thing you're spreading might be null, the plain ... would crash. ...? skips it gracefully:
List<int>? extra = maybeGetItems(); // could be null
var all = [1, 2, ...?extra]; // if extra is null, just [1, 2]
Collection-if — conditionally include an element
Add an item only when a condition holds, right inside the literal:
bool isAdmin = true;
var menu = [
'Home',
'Profile',
if (isAdmin) 'Admin Panel', // included only when isAdmin is true
];
// → [Home, Profile, Admin Panel]
Flutter developers live in this one — conditionally adding widgets to a list without breaking the declarative flow.
Collection-for — build elements with a loop, inline
Generate elements from another iterable, right inside the brackets:
var nums = [1, 2, 3];
var doubled = [for (final n in nums) n * 2]; // [2, 4, 6]
And you can combine collection-for with collection-if for a filter-and-transform in one breath:
var nums = [1, 2, 3, 4, 5, 6];
var evenSquares = [
for (final n in nums)
if (n.isEven) n * n,
]; // [4, 16, 36]
Read that almost like English: "for each n, if it's even, include its square." That's the elegance Dart's collection literals are known for.
const and unmodifiable collections
You can make a collection a compile-time constant (recall const from Part 2). A const collection is deeply immutable — you can't add, remove, or change anything:
const days = ['Mon', 'Tue', 'Wed'];
// days.add('Thu'); // ❌ Unsupported — a const list is frozen
A neat detail: the const can sit on the value even when the variable isn't const, which is great for fixed lookup tables you don't want mutated:
final colors = const ['red', 'green', 'blue']; // immutable contents, reassignable variable
Practice Challenges
Give each a real attempt first.
Challenge 1 — Which collection? For each task, name the best collection: (a) the order of pages a user visited, (b) the set of unique tags on a post, (c) a phone book mapping names to numbers.
Show solution
(a) List — order matters and pages can repeat. (b) Set — unique, membership matters, order doesn't. (c) Map — name (key) → number (value) lookups.
Challenge 2 — Deduplicate while preserving the idea. Given var nums = [3, 1, 3, 2, 1];, produce a list of the unique values.
Show solution
var unique = nums.toSet().toList(); // [3, 1, 2]
.toSet() drops duplicates; .toList() turns it back into a list. (Default Set keeps insertion order, so you get [3, 1, 2].)
Challenge 3 — Spot the bug. A developer wants an empty set of ints but gets weird type errors. What's wrong?
var ids = {};
ids.add(1);
Show solution
{} is an empty Map, not a set — so ids.add(1) doesn't even exist on it. Fix by annotating the type:
var ids = <int>{}; // now it's a Set<int>
ids.add(1);
Challenge 4 — One-line filtered build. From [1..10], build a list of the squares of the odd numbers using collection-for and collection-if.
Show solution
var nums = [for (var i = 1; i <= 10; i++) i];
var oddSquares = [
for (final n in nums)
if (n.isOdd) n * n,
];
// → [1, 9, 25, 49, 81]
Challenge 5 — Merge with overrides. Given defaults = {'size': 'M', 'color': 'red'} and userPrefs = {'color': 'blue'}, produce a merged map where the user's choices win.
Show solution
var merged = {...defaults, ...userPrefs};
// → {size: M, color: blue} — userPrefs spread last, so its color wins
When the same key appears twice in a map literal, the later one takes effect — so spread the overrides last.
Challenge 6 — Safe spread. List<String>? tags might be null. Build a list ['post', ...tags?]-style without crashing when it's null.
Show solution
var all = ['post', ...?tags]; // null-aware spread skips it if tags is null
...? is the null-aware spread — if tags is null, nothing is added instead of throwing.
Check Yourself (Q&A)
Q1. List vs Set — when do I pick Set? When you need unique values and/or fast "does it contain X?" checks, and order isn't the point. Lists keep order and allow duplicates but membership checks scan the whole thing.
Q2. What does {} create?
An empty Map, not a Set. For an empty set you must annotate: <String>{}.
Q3. Why is a Map lookup nullable?
Because the key might not exist — map[key] returns null for a missing key rather than crashing. Use ?? default to handle the miss.
Q4. What's the difference between ... and ...??
... spreads a collection's elements into a literal; ...? does the same but safely skips the operand when it's null.
Q5. Are map/where results lists?
No — they return lazy Iterables. Call .toList() (or .toSet()) to materialize them when you need a concrete collection.
Q6. Can I mutate a const list?
No. const collections are deeply immutable — add/remove/update all throw. Use a normal final list if you need to mutate the contents.
Wrapping Up
- List = ordered + indexed + duplicates allowed →
[...]. - Set = unique values + fast membership →
<T>{...}(and remember{}is a Map!). - Map = key→value lookups, missing keys return null →
{k: v}. - Build them beautifully with
...(spread),...?(null-aware spread), collection-if, and collection-for — often combined into a single declarative literal. constcollections are deeply frozen.
Next, in Part 7, we tackle functions: positional, named, and optional parameters, the arrow => shorthand, closures, and returning multiple values with records.