Control Flow in Dart
This is Part 9 of the Dart Fundamentals series — and the grand finale of the language core. Control flow is how your program decides and repeats. The basics (if, loops) will feel familiar from any C-style language. But Dart 3 turned switch into one of the most powerful features in the language with pattern matching, and that's where we'll spend the real energy.
We'll start gentle and build up.
if / else if / else
The bread and butter. Remember from Part 3: the condition must be a real bool — no truthy/falsy shortcuts:
int score = 72;
if (score >= 90) {
print('A');
} else if (score >= 60) {
print('B');
} else {
print('Fail');
}
For a simple either/or that produces a value, the ternary ?: is cleaner than a full if:
String result = score >= 60 ? 'Pass' : 'Fail';
And recall the null-aware cousins from Part 5: a ?? b ("a, or b if null") and a?.b are themselves compact forms of conditional logic.
Loops
for and for-in
The classic counting loop, and the cleaner iterate-over-a-collection form:
for (var i = 0; i < 3; i++) {
print(i); // 0, 1, 2
}
var fruits = ['apple', 'banana'];
for (final fruit in fruits) { // prefer this for collections
print(fruit);
}
Use for-in whenever you don't actually need the index — it's clearer and avoids off-by-one bugs.
while and do-while
var n = 3;
while (n > 0) { // checks BEFORE each iteration
print(n);
n--;
}
do {
print('runs at least once'); // checks AFTER — body always runs once
} while (false);
break and continue
break exits the loop entirely; continue skips to the next iteration:
for (var i = 0; i < 10; i++) {
if (i == 5) break; // stop at 5
if (i.isEven) continue; // skip even numbers
print(i); // 1, 3
}
Tip: for transforming or filtering a collection, prefer the functional methods from Part 6 (
map,where) or collection-for over a manual loop — they're more declarative and harder to get wrong. Reach for explicit loops when you need side effects, early exits, or index math.
switch — and why Dart's is special
Here's the first surprise for anyone coming from C, Java, or JavaScript: Dart's switch has no implicit fallthrough. Each non-empty case automatically jumps to the end after it runs — no break needed:
switch (command) {
case 'open':
openFile();
// no break! it stops here automatically
case 'close':
closeFile();
default:
print('Unknown command');
}
This kills the all-time classic bug of forgetting a break and silently falling through. If you genuinely want several cases to share a body, you stack the labels or use a logical-or pattern (below):
switch (day) {
case 'Sat':
case 'Sun':
print('Weekend');
default:
print('Weekday');
}
That was the warm-up. Now the real power.
Patterns: the big Dart 3 idea
A pattern does two things at once: it tests whether a value has a certain shape, and it extracts (destructures) data out of it. Patterns show up in switch, in if-case, and even in variable declarations. Once they click, you'll wonder how you lived without them.
Destructuring in variable declarations
You've already seen a taste with records in Part 7. Patterns let you unpack lists, maps, and records right in a declaration:
// record
var (name, age) = ('Asha', 30);
// list
var [first, second, third] = [1, 2, 3];
// map
var {'id': id, 'title': title} = {'id': 7, 'title': 'Hi'};
// swap two variables with no temp!
var (a, b) = (1, 2);
(a, b) = (b, a); // a is now 2, b is now 1
That last one — swapping without a temp variable — is a tiny thing that delights everyone.
switch expressions — switch that returns a value
A regular switch does something. A switch expression produces a value, with a much terser syntax: no case keyword, arrows instead of colons, commas between arms, and _ for the default:
String describe(int code) => switch (code) {
200 => 'OK',
404 => 'Not Found',
500 => 'Server Error',
_ => 'Unknown', // _ is the catch-all (wildcard)
};
print(describe(404)); // Not Found
Compare that to a five-line statement switch with a temp variable. The expression form is assignable, returnable, and reads top to bottom like a lookup table.
Logical-or and relational patterns
The arms can be richer than single constants. Combine values with ||, and use relational/comparison patterns with &&:
String classify(int n) => switch (n) {
0 => 'zero',
1 || 2 || 3 => 'small', // logical-or pattern
>= 4 && <= 9 => 'medium', // relational + logical-and
_ => 'large',
};
classify(2); // small
classify(7); // medium
classify(50);// large
>= 4 && <= 9 is a relational pattern — "the value is in this range" — expressed declaratively. No more if (n >= 4 && n <= 9) ladders.
Guard clauses with when
Sometimes a pattern matches the shape, but you need an extra runtime condition. Add a when guard:
String ticket(int age) => switch (age) {
var a when a < 0 => 'invalid',
var a when a < 13 => 'child',
var a when a < 65 => 'adult',
_ => 'senior',
};
Here var a captures the value, and when adds the boolean test. The arm only matches if both the pattern and the guard hold.
if-case — match a single shape
You don't always want a whole switch. When you want to test one value against one pattern (and destructure it if it matches), use if-case:
// Suppose json['point'] might be a [x, y] list
Object data = [3, 4];
if (data case [int x, int y]) {
// only runs if data is a 2-element list of ints,
// AND binds x = 3, y = 4 in here
print('Point at $x, $y'); // Point at 3, 4
}
This is wonderful for digging into dynamic data (like decoded JSON): "if this has the shape I expect, pull the pieces out; otherwise skip." One construct does the type-check, the shape-check, and the extraction.
You can add a guard here too:
if (data case [int x, int y] when x == y) {
print('On the diagonal');
}
Object patterns — destructuring your own types
Patterns also match the fields of your objects (using the named-field syntax :fieldName):
class Point {
final int x, y;
Point(this.x, this.y);
}
var p = Point(0, 5);
switch (p) {
case Point(x: 0, y: var y):
print('On the Y axis at $y'); // On the Y axis at 5
case Point(x: var x, y: 0):
print('On the X axis at $x');
default:
print('Somewhere else');
}
Point(x: 0, y: var y) matches only when x is 0, and binds the y field to a new variable y. Type-check, field-match, and extraction — all in one pattern.
Exhaustiveness — the compiler has your back
Here's the feature that ties patterns to real safety. A switch over a type with a known, finite set of possibilities must be exhaustive — handle every case — or the compiler complains. This is strongest with enums (covered in the OOP series) and sealed classes (class modifiers):
enum Status { active, paused, stopped }
String label(Status s) => switch (s) {
Status.active => 'Running',
Status.paused => 'Paused',
Status.stopped => 'Stopped',
// no default needed — all cases covered.
};
If you later add Status.archived, every non-exhaustive switch becomes a compile error pointing you straight at the code you forgot to update. That's a refactor superpower: the compiler turns "did I handle the new case everywhere?" from a manual hunt into a guaranteed checklist.
Practice Challenges
Give each a genuine try first.
Challenge 1 — Ternary refactor. Rewrite this as a single line:
String s;
if (n % 2 == 0) {
s = 'even';
} else {
s = 'odd';
}
Show solution
String s = n.isEven ? 'even' : 'odd';
A simple either/or assignment is exactly what the ternary is for. (isEven reads even nicer than n % 2 == 0.)
Challenge 2 — No-break switch. A colleague from Java writes this and is confused why "B then default" don't both run for 'b'. Explain.
switch (grade) {
case 'a': print('Excellent');
case 'b': print('Good');
default: print('Done');
}
Show solution
In Dart there's no fallthrough — each non-empty case auto-stops at its end. So 'b' prints only Good. (In Java, missing breaks would print Good and Done.) No break is needed in Dart; if you wanted shared behaviour you'd stack empty case labels.
Challenge 3 — Switch expression. Convert a day number (1–7) to a name using a switch expression, defaulting to '??'.
Show solution
String dayName(int d) => switch (d) {
1 => 'Mon',
2 => 'Tue',
3 => 'Wed',
4 => 'Thu',
5 => 'Fri',
6 || 7 => 'Weekend',
_ => '??',
};
Arrows, commas, || to combine 6 and 7, _ for the fallback. Terse and returnable.
Challenge 4 — Range classification. Write bmiCategory(double bmi) returning 'under' (< 18.5), 'normal' (18.5–24.9), 'over' (25–29.9), 'obese' (>= 30) using relational patterns.
Show solution
String bmiCategory(double bmi) => switch (bmi) {
< 18.5 => 'under',
>= 18.5 && < 25 => 'normal',
>= 25 && < 30 => 'over',
_ => 'obese',
};
Relational patterns (<, >=) combined with && express each band declaratively — no if/else if ladder.
Challenge 5 — if-case destructure. Object input might be a ['move', int steps] list. If so, print Moving N steps; otherwise print Bad command.
Show solution
void handle(Object input) {
if (input case ['move', int steps]) {
print('Moving $steps steps');
} else {
print('Bad command');
}
}
handle(['move', 5]); // Moving 5 steps
handle(['jump']); // Bad command
The pattern checks it's a 2-element list whose first element is 'move' and second is an int, binding steps in one shot.
Challenge 6 — Exhaustive enum. Given enum Light { red, yellow, green }, write action(Light) returning the instruction for each, with no default. Then explain what happens if someone adds Light.flashing.
Show solution
enum Light { red, yellow, green }
String action(Light l) => switch (l) {
Light.red => 'Stop',
Light.yellow => 'Slow',
Light.green => 'Go',
};
Because all enum values are covered, no default is needed. If someone adds Light.flashing, this switch becomes a compile error — the analyzer flags it as non-exhaustive, forcing you to handle the new value before the code will build. That's the safety net.
Check Yourself (Q&A)
Q1. Does Dart's switch fall through like C/Java?
No. Each non-empty case stops automatically — no break required, and no accidental fallthrough. Stack empty case labels (or use || patterns) to share a body.
Q2. Statement switch vs switch expression?
A statement switch performs actions; a switch expression (arrows, commas, _) evaluates to a value you can return or assign.
Q3. What is a pattern?
A construct that simultaneously tests a value's shape/type and destructures data out of it — usable in switch, if-case, and variable declarations.
Q4. When do I use if-case instead of switch?
When you're matching a single value against a single pattern (often to destructure dynamic data like decoded JSON), rather than branching among many cases.
Q5. What does a when guard do?
Adds an extra boolean condition to a pattern; the arm matches only if the pattern matches and the guard is true.
Q6. What is exhaustiveness and why care?
For finite types (enums, sealed classes), a switch must cover every case or it won't compile. Adding a new case turns every unhandled switch into a compile error — a guarantee you've updated all the code that needs it.
Wrapping Up
if/else, the ternary?:, and the loops (for,for-in,while,do-while,break/continue) are your familiar tools — prefer collection methods for pure transform/filter.- Dart's
switchhas no fallthrough — a safer default out of the box. - Patterns test shape and destructure in one move — in declarations,
switch, andif-case. switchexpressions return values; logical-or/relational patterns,whenguards, and object patterns make branching declarative.- Exhaustiveness over enums and
sealedclasses turns "did I cover every case?" into a compile-time guarantee.
That completes the language core of the Dart Fundamentals series! 🎉 To lock it all in, head to Part 10 — 100 practice questions with hints and full solutions, ramping from absolute basics to advanced patterns.