← Back to blog
Dart Fundamentals · Part 9 of 10
July 7, 202611 min read

Control Flow in Dart: if, switch, Loops & Pattern Matching

DartFlutter

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 switch has no fallthrough — a safer default out of the box.
  • Patterns test shape and destructure in one move — in declarations, switch, and if-case.
  • switch expressions return values; logical-or/relational patterns, when guards, and object patterns make branching declarative.
  • Exhaustiveness over enums and sealed classes 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 10100 practice questions with hints and full solutions, ramping from absolute basics to advanced patterns.