Dart — Control Flow, Exhaustiveness & Pattern Matching
Exhaustive Switches — The Compiler Safety Net
// Dart 3 exhaustive switches: if you handle all subtypes of a sealed type,
// the compiler ENFORCES completeness — no `default` needed, and adding a
// new subtype causes a compile error at every non-exhaustive switch.
sealed class Result<T> {
const Result();
}
class Success<T> extends Result<T> {
final T value;
const Success(this.value);
}
class Failure<T> extends Result<T> {
final String error;
const Failure(this.error);
}
class Loading<T> extends Result<T> {
const Loading();
}
// Exhaustive — all three subtypes handled. No `default` needed.
String describe<T>(Result<T> r) => switch (r) {
Success(:final value) => 'OK: $value', // destructuring pattern: extracts .value
Failure(:final error) => 'ERR: $error',
Loading() => 'Loading...',
};
// If you later add `class Idle<T> extends Result<T> {}`, every switch like the
// above becomes a COMPILE ERROR: "Idle is not handled." This is the safety net —
// you can't forget a case.
The default Trap
enum Status { pending, active, completed }
// ❌ Anti-pattern: using `default` in an enum switch.
String labelBad(Status s) => switch (s) {
Status.pending => 'Pending',
Status.active => 'Active',
_ => 'Completed', // wildcard catches everything else — including FUTURE values
};
// If `Status.cancelled` is added later, `_` silently catches it and returns
// 'Completed' — a silent semantic bug. The compiler can't warn you.
// ✓ Correct: handle every case explicitly, no wildcard.
String labelGood(Status s) => switch (s) {
Status.pending => 'Pending',
Status.active => 'Active',
Status.completed => 'Completed',
// Adding Status.cancelled here → compile error: non-exhaustive switch.
// The compiler FORCES you to handle the new case.
};
Pattern Matching — Destructuring & Guards
// Dart 3 patterns: destructuring, type matching, guard clauses (`when`).
// Record destructuring in switch:
(int x, int y) point = (3, 4);
String quadrant = switch (point) {
(0, 0) => 'origin',
(int x, 0) when x > 0 => 'positive x-axis',
(int x, 0) => 'negative x-axis',
(0, int y) => 'y-axis',
(int x, int y) when x > 0 && y > 0 => 'Q1',
(int x, int y) when x < 0 && y > 0 => 'Q2',
(int x, int y) when x < 0 && y < 0 => 'Q3',
_ => 'Q4',
};
// Type + destructuring patterns:
sealed class Shape {}
class Circle extends Shape { final double r; Circle(this.r); }
class Rectangle extends Shape { final double w, h; Rectangle(this.w, this.h); }
class Triangle extends Shape { final double a, b, c; Triangle(this.a, this.b, this.c); }
double area(Shape s) => switch (s) {
Circle(:final r) => 3.14159 * r * r,
Rectangle(:final w, :final h) => w * h,
Triangle(:final a, :final b, :final c) {
final semi = (a + b + c) / 2;
return (semi * (semi - a) * (semi - b) * (semi - c)).abs();
}
};
// `if` with patterns (Dart 3):
void handleShape(Shape s) {
if (s case Circle(:final r) when r > 100) {
print('Large circle: $r');
} else if (s case Rectangle(:final w, :final h) when w == h) {
print('Square: ${w}x${h}');
}
}
// Map pattern (destructuring by key):
switch (json) {
case {'type': 'point', 'x': int x, 'y': int y}:
print('Point at ($x, $y)');
case {'type': 'circle', 'radius': double r}:
print('Circle radius $r');
case {'type': String type}:
print('Unknown type: $type');
}
Switch Expressions vs Statements
// Switch EXPRESSION — returns a value, no `break`, arms are `=>`:
final status = switch (code) {
200 || 201 => 'success', // OR pattern: multiple values, one arm
400 => 'bad request',
401 || 403 => 'auth error',
404 => 'not found',
>= 500 => 'server error', // relational pattern: >= 500
_ => 'unknown',
};
// Switch STATEMENT — for side effects, `break` required (no implicit fallthrough):
void handle(int code) {
switch (code) {
case 200:
case 201: // empty case falls through to next (only for empty cases)
print('Success');
break;
case 404:
print('Not found');
break;
default:
print('Unknown');
}
}
// ❌ Anti-pattern: forgetting `break` in a switch statement.
// switch (x) { case 1: print('one'); case 2: print('two'); } // ✗ compile error
// Dart requires break/return/throw/continue after each non-empty case body.
// Only EMPTY cases (no body) fall through to the next.
Loop Semantics — Capture & forEach Trap
// Dart's `for` loop variable is a SINGLE variable reassigned each iteration
// (like JS `var`, NOT like JS `let` or Rust). Closures capture it by reference.
// ❌ Anti-pattern: closures in a loop all capture the same variable.
var callbacks = <int Function()>[];
for (var i = 0; i < 3; i++) {
callbacks.add(() => i);
}
print(callbacks.map((f) => f()).toList()); // [3, 3, 3] — all see final value of i
// ✓ Correct: capture per-iteration in a `final` local.
var callbacksFixed = <int Function()>[];
for (var i = 0; i < 3; i++) {
final captured = i; // fresh per iteration, immutable
callbacksFixed.add(() => captured);
}
print(callbacksFixed.map((f) => f()).toList()); // [0, 1, 2]
// `forEach` is a METHOD, not a loop — `continue`/`break` don't work inside it.
// ❌ Anti-pattern: using `continue` in `forEach`.
[1, 2, 3].forEach((x) {
// if (x == 2) continue; // ✗ compile error — no continue in a callback
if (x == 2) return; // ✓ `return` skips this callback invocation (like continue)
print(x); // 1, 3
});
// ✓ For `continue`/`break` semantics, use a real loop:
for (var x in [1, 2, 3]) {
if (x == 2) continue;
print(x); // 1, 3
}
assert — Debug-Only Invariants
// assert() runs ONLY in debug (JIT/VM). In AOT (release), it's completely stripped.
// Use for development-time invariants — conditions that indicate a bug if false.
void transfer(Account from, Account to, int amount) {
assert(amount > 0, 'Transfer amount must be positive');
assert(from.balance >= amount, 'Insufficient funds — caller should check first');
// These assertions catch programming errors during development.
// In release, they vanish — zero overhead. But the checks are NOT runtime validation.
// For user-facing validation, use explicit checks:
if (amount <= 0) throw ArgumentError('Amount must be positive: $amount');
if (from.balance < amount) throw StateError('Insufficient funds');
from.debit(amount);
to.credit(amount);
}
// assert with a lambda body for expensive checks (only evaluated in debug):
assert(() {
_validateInvariants(); // complex check — stripped in release
return true;
}());
💡 Tips & Tricks
- Idiom: remove
default/_from switches over sealed types and enums — let the compiler enforce exhaustiveness. Adding a new case causes a compile error at every switch, forcing you to handle it. This is Dart 3's most powerful refactoring safety net. - Idiom: use switch expressions for value mapping —
switch (x) { 200 => 'OK', _ => 'ERR' }is concise and returns a value. Use for status code mapping, enum-to-string, result unwrapping. Prefer overif-elsechains for multi-way dispatch. - Idiom: use patterns for destructuring —
case Success(:final value)extracts.valuedirectly in the switch arm. Eliminates explicit casts and field access. Combines type checking + extraction in one expression. - Idiom: use guard clauses (
when) for additional conditions —case (int x, int y) when x > 0 && y > 0adds a boolean predicate to a pattern. Use for sub-case filtering without nestedif. - Debug:
assert(() { _validateInvariants(); return true; }())— the lambda body is fully stripped in AOT (including side effects). Use for expensive debug-only validation that must not run in production.
⚠️ Edge Cases & Gotchas
- Conditions must be
bool:if (x)wherexisint,String,List, ornullis a compile error. Dart has no truthy/falsy. Compare explicitly:if (x != null),if (list.isNotEmpty). switchdoesn't fall through by default: each non-empty case must end withbreak/return/throw/continue. Only empty cases (no body) fall through. Forgetting the terminator is a compile error (unlike C/Java).default/_defeats exhaustiveness: adding_to a switch over a sealed type makes it non-exhaustive-checked — new subtypes are silently caught by the wildcard. Remove_for sealed types and enums.assertis stripped in AOT: the entire expression (including side effects) vanishes in release. Never put business logic, side effects, or runtime validation inassert.forloop variable is shared across iterations: closures capturingisee the final value. Usefinal captured = i;inside the loop body for per-iteration capture.forEachdoesn't supportcontinue/break: it's a method taking a callback. Usereturnto skip (likecontinue), but there's nobreakequivalent. Use a realforloop for control flow.for-inonMapiterates keys:for (var k in map)iterates keys (aMapisIterableof keys). Usefor (var entry in map.entries)for key-value pairs.- Labeled
breakis rarely idiomatic:outer: for (...) { for (...) { break outer; } }works but signals overly complex control flow. Extract to a function and usereturn. do-whileruns at least once: the body executes before the condition check. Use for read-then-check patterns (e.g., prompt → validate → repeat).
🧠 Spot the Bug
A team adds Status.cancelled to their enum. The UI silently shows "Completed" for cancelled orders:
enum Status { pending, active, completed, cancelled }
String label(Status s) => switch (s) {
Status.pending => 'Pending',
Status.active => 'Active',
_ => 'Completed', // catches both completed AND cancelled
};
Why is this a silent bug, and how to prevent it permanently?
Answer
The _ (wildcard) catches every unhandled case — including the newly added Status.cancelled. It returns 'Completed' for cancelled orders, which is semantically wrong. The wildcard makes the switch non-exhaustive-checked, so the compiler can't flag the missing case.
The fix — remove _, handle every case explicitly:
enum Status { pending, active, completed, cancelled }
String label(Status s) => switch (s) {
Status.pending => 'Pending',
Status.active => 'Active',
Status.completed => 'Completed',
Status.cancelled => 'Cancelled',
};
Now if Status.shipped is added later, this switch becomes a compile error: "The switch expression does not exhaustively cover all possible cases." The compiler forces you to handle shipped — no silent bugs.
The rule: never use _ or default in switches over enums or sealed types. Let exhaustiveness checking be your safety net. The wildcard is acceptable for open types (int, String) where you can't enumerate all cases.