Dart 10 🎯 Switch Statements, Pattern Matching, and Switch Expressions (Dart 3+)
Dart 3 transformed the switch statement from a simple multi-way branch into a full pattern-matching construct. The old switch compared a value against constant cases. The new switch matches against patterns—which can be constants, types, collections, records, and objects—and can destructure the matched value in the process. This is not an incremental improvement. It is the language feature that makes Dart a viable choice for expressing complex conditional logic without the nesting and repetition that plagued earlier versions.
The switch construct now exists in two forms. The switch statement executes code for the matching case; the switch expression produces a value. The expression form is what changed the language most: it can appear anywhere a value is expected, including inside widget trees, return statements, and variable assignments. Combined with sealed classes and exhaustiveness checking, switch expressions let the compiler verify that every possible case is handled, turning a class of runtime errors into compile-time errors.
This chapter covers switch statements, switch expressions, pattern matching with constants, types, collections, records, and objects, guard clauses, exhaustiveness checking with enums and sealed classes, and the patterns that make conditional logic readable.
Key point: Switch expressions produce a value and can appear anywhere an expression is allowed. Cases are patterns, not just constants—they can match types, destructure collections and records, and use guard clauses with when. When the matched value is an enum or sealed type, the compiler enforces exhaustiveness.
Why Dart 3 rebuilt switch
The constant-only limitation problem. Before Dart 3, a switch case could only be a compile-time constant. Matching on type required if (x is String) ... else if (x is int) ... chains. Matching on structure required manual property access and nested conditions. The new pattern matching removes these limitations: a case can be a type pattern, a collection pattern, a record pattern, or an object pattern .
The statement-only limitation problem. The old switch was a statement. It could not produce a value, so a conditional selection that needed to assign a result required either a helper method or a chain of ternaries. The switch expression returns a value and can appear in any expression position, which is why it became the idiomatic way to map an enum to a widget or a status to a label .
The exhaustiveness problem. Functional languages with algebraic data types let the compiler verify that every case is handled. Dart could do this for enums and booleans, but not for class hierarchies because subclasses could be declared anywhere. The sealed modifier, introduced alongside patterns, restricts subclasses to the same library, which makes the set of subtypes enumerable and exhaustiveness checkable .
The destructuring problem. When matching a complex value—a record with several fields, a list with a specific structure, an object with properties—the old switch required matching the whole value and then extracting fields manually. Pattern matching destructures in the case clause itself, binding fields to variables in a single step .
The readability problem. A chain of if/else if with type checks and property extractions is noisy. A switch expression with object patterns is declarative: it states what shape each case has, and the compiler enforces that the shapes are valid and exhaustive. The result is code that reads like a specification of the possible cases rather than a sequence of procedural checks .
a. Switch statements
A switch statement evaluates a value against a series of case patterns. When a pattern matches, its body executes. Dart 3 does not require break at the end of non-empty cases; each case implicitly breaks after completion .
switch (command) {
case 'OPEN':
executeOpen();
case 'CLOSED':
executeClosed();
default:
executeUnknown();
}
Empty cases fall through to the next case, allowing cases to share a body:
switch (command) {
case 'CLOSED':
case 'PENDING':
case 'APPROVED':
case 'DENIED':
executeClosed();
case 'OPEN':
executeOpen();
default:
executeUnknown();
}
For non-sequential fall-through, a continue statement with a label is used, though this is rare in modern Dart code .
The default clause executes when no case matches. A wildcard _ pattern serves the same purpose and is often used in switch expressions where default is not allowed .
b. Switch expressions
A switch expression produces a value based on which case matches. The syntax differs from the statement form: cases do not start with case, and each body is a single expression rather than a block of statements .
var label = switch (status) {
Status.pending => 'Pending',
Status.active => 'Active',
Status.closed => 'Closed',
};
The expression form can appear anywhere an expression is allowed—assignments, arguments, return statements, and nested inside other expressions .
Widget buildIcon(Status status) {
return switch (status) {
Status.pending => const Icon(Icons.hourglass),
Status.active => const Icon(Icons.check),
Status.closed => const Icon(Icons.close),
};
}
When the matched value is an always-exhaustive type—an enum, a sealed class, a boolean, or a nullable version of one—the compiler requires every case to be handled. A missing case is a compile error, not a runtime fallthrough .
c. Pattern matching: constants, types, and guards
Case patterns can match constants, types, and combinations with guard clauses.
Constant patterns match if the value equals the constant :
var grade = switch (score) {
100 => 'Perfect',
90 => 'Excellent',
_ => 'Other',
};
Type patterns match if the value is of the specified type, and bind the value to a variable of that type:
var description = switch (value) {
int n => 'integer: $n',
String s => 'string: $s',
List() => 'a list',
_ => 'something else',
};
The variable n in int n is bound to the matched value, and its type is narrowed to int within the case body. This is type promotion through patterns .
Guard clauses add a condition with the when keyword:
var size = switch (value) {
int n when n < 10 => 'small',
int n when n < 100 => 'medium',
int _ => 'large',
_ => 'unknown',
};
The guard is evaluated only if the pattern matches. If the guard is false, matching continues to the next case .
d. Destructuring collections, records, and objects
Pattern matching can destructure complex values, binding their constituent parts to local variables.
List patterns match lists of a specific shape:
var result = switch (list) {
[var first, var second, ...] => 'Starts with $first and $second',
[] => 'Empty',
_ => 'Other',
};
The ... is a rest element that matches any number of remaining elements. List patterns match by structure and length, not by identity .
Record patterns destructure records into their fields:
var point = (2, 3);
var quadrant = switch (point) {
(0, 0) => 'Origin',
(var x, var y) when x > 0 && y > 0 => 'Quadrant I',
(var x, var y) when x < 0 && y > 0 => 'Quadrant II',
(var x, var y) when x < 0 && y < 0 => 'Quadrant III',
(var x, var y) when x > 0 && y < 0 => 'Quadrant IV',
_ => 'On axis',
};
The record pattern (var x, var y) matches a two-element record and binds its positional fields to x and y .
Object patterns match instances of a class and destructure their properties using getters:
sealed class Shape {}
class Circle extends Shape {
final double radius;
Circle(this.radius);
}
class Square extends Shape {
final double side;
Square(this.side);
}
double area(Shape shape) {
return switch (shape) {
Circle(radius: var r) => 3.14159 * r * r,
Square(side: var s) => s * s,
};
}
The Circle(radius: var r) pattern matches a Circle instance and binds its radius getter to the variable r. Object patterns only support named fields; positional constructor arguments are not matched .
e. Exhaustiveness and sealed classes
Exhaustiveness checking ensures that every possible value is handled by a switch. The compiler enforces this when the matched type is always-exhaustive: bool, null, an enum, a sealed type, or a record/collection type whose fields are themselves always-exhaustive .
For an enum, listing every value is sufficient:
enum Status { pending, active, closed }
var label = switch (status) {
Status.pending => 'Pending',
Status.active => 'Active',
Status.closed => 'Closed',
};
For a sealed class, listing every direct subtype is sufficient:
sealed class Result {}
class Success extends Result {
final String data;
Success(this.data);
}
class Failure extends Result {
final String error;
Failure(this.error);
}
var message = switch (result) {
Success(data: var d) => 'OK: $d',
Failure(error: var e) => 'Error: $e',
};
The sealed modifier restricts subclasses to the same library, which makes the set of subtypes known to the compiler. This is what enables exhaustiveness checking for class hierarchies .
If a case is missing, the compiler reports an error naming the unhandled subtype . This turns a potential runtime null or fallthrough into a compile-time error.
For types that are not always-exhaustive—such as a regular (non-sealed) class hierarchy—a default or wildcard case is required if the switch is used as an expression, but exhaustiveness is not enforced .
f. Switch expressions in Flutter and widget trees
The switch expression’s ability to produce a value makes it the natural replacement for chains of ternaries in widget trees. A Widget is a value, and selecting among widgets based on a status is exactly what a switch expression does .
Widget buildStatusIcon(Status status) {
return switch (status) {
Status.pending => const CircularProgressIndicator(),
Status.active => const Icon(Icons.check_circle, color: Colors.green),
Status.closed => const Icon(Icons.cancel, color: Colors.grey),
};
}
Before Dart 3, this pattern required either a helper method with an if/else chain or nested ternaries that were hard to read. The switch expression makes the mapping declarative and exhaustiveness-checked. If a new status is added to the enum, every switch expression over it becomes a compile error until the new case is handled .
The switch expression also works inside collection literals and arguments:
Column(
children: [
Text(switch (status) {
Status.pending => 'Waiting...',
Status.active => 'Running',
Status.closed => 'Done',
}),
],
)
Complete Example Session
// ============================================
// PART 1: BASIC SWITCH STATEMENT
// ============================================
void handleCommand(String command) {
switch (command) {
case 'OPEN':
print('Opening...');
case 'CLOSED':
print('Closing...');
default:
print('Unknown command');
}
}
// ============================================
// PART 2: SWITCH EXPRESSION
// ============================================
String describe(int score) {
return switch (score) {
100 => 'Perfect',
90 => 'Excellent',
>= 80 => 'Good',
>= 70 => 'Average',
_ => 'Needs improvement',
};
}
// ============================================
// PART 3: ENUM EXHAUSTIVENESS
// ============================================
enum Status { pending, active, closed }
String label(Status status) {
return switch (status) {
Status.pending => 'Waiting',
Status.active => 'Running',
Status.closed => 'Done',
};
}
// ============================================
// PART 4: TYPE PATTERNS
// ============================================
String describe2(Object value) {
return switch (value) {
int n => 'integer: $n',
String s => 'string: $s',
List() => 'a list',
_ => 'something else',
};
}
// ============================================
// PART 5: GUARD CLAUSES WITH when
// ============================================
String size(int n) {
return switch (n) {
< 0 => 'negative',
0 => 'zero',
> 0 && < 10 => 'small',
>= 10 && < 100 => 'medium',
_ => 'large',
};
}
// ============================================
// PART 6: RECORD DESTRUCTURING
// ============================================
String quadrant((int, int) point) {
return switch (point) {
(0, 0) => 'Origin',
(var x, var y) when x > 0 && y > 0 => 'Quadrant I',
(var x, var y) when x < 0 && y > 0 => 'Quadrant II',
(var x, var y) when x < 0 && y < 0 => 'Quadrant III',
(var x, var y) when x > 0 && y < 0 => 'Quadrant IV',
_ => 'On axis',
};
}
// ============================================
// PART 7: LIST PATTERN
// ============================================
String describeList(List<int> list) {
return switch (list) {
[] => 'empty',
[var single] => 'one element: $single',
[var first, var second] => 'two elements: $first, $second',
[var first, ...] => 'starts with $first',
};
}
// ============================================
// PART 8: SEALED CLASS EXHAUSTIVENESS
// ============================================
sealed class Result {}
class Success extends Result {
final String data;
Success(this.data);
}
class Failure extends Result {
final String error;
Failure(this.error);
}
String message(Result result) {
return switch (result) {
Success(data: var d) => 'OK: $d',
Failure(error: var e) => 'Error: $e',
};
}
// ============================================
// PART 9: OBJECT PATTERN WITH NESTED RECORD
// ============================================
class Employee {
final String name;
final (int, int) coordinates;
Employee(this.name, this.coordinates);
}
String describeEmployee(Employee e) {
return switch (e) {
Employee(name: var n, coordinates: (0, 0)) => '$n at origin',
Employee(name: var n, coordinates: (var x, var y)) =>
'$n at ($x, $y)',
};
}
// ============================================
// PART 10: SWITCH EXPRESSION IN WIDGET TREE
// ============================================
// Conceptual Flutter example
// Widget buildIcon(Status status) {
// return switch (status) {
// Status.pending => const CircularProgressIndicator(),
// Status.active => const Icon(Icons.check),
// Status.closed => const Icon(Icons.close),
// };
// }
These ten parts cover switch statements, switch expressions, enum exhaustiveness, type patterns, guard clauses, record destructuring, list patterns, sealed class exhaustiveness, nested object/record patterns, and the widget-tree use case.
Quick Reference
Switch Statement vs Expression
| Aspect | Statement | Expression |
|---|---|---|
| Produces value | No | Yes |
| Syntax | case pattern: | pattern => |
| Body | Statements | Single expression |
| Exhaustive | Only for always-exhaustive types | Required for always-exhaustive types |
| Use case | Side effects | Value selection |
Pattern Types
| Pattern | Example | Matches |
|---|---|---|
| Constant | 100, 'OPEN' | Equality |
| Type | int n, String s | Runtime type |
| List | [var a, var b] | List structure |
| Record | (var x, var y) | Record fields |
| Object | Circle(radius: var r) | Class instance and properties |
| Wildcard | _ | Anything |
| Logical-or | 1 || 2 || 3 | Any of the alternatives |
Exhaustive Types
| Type | Exhaustive |
|---|---|
bool | Yes |
| Enum | Yes |
| Sealed class | Yes |
| Record of exhaustive types | Yes |
| Regular class | No (default required for expressions) |
Guard Clauses
| Syntax | Effect |
|---|---|
pattern when condition | Match only if condition is true |
int n when n > 0 | Integer that is positive |
Best Practices
✅ Do This:
// Switch expression for value selection
var label = switch (status) {
Status.pending => 'Pending',
Status.active => 'Active',
Status.closed => 'Closed',
};
// Sealed class for exhaustiveness
sealed class Result {}
class Success extends Result {}
class Failure extends Result {}
// Guard clause for conditions
int n when n > 0 => 'positive',
// Object pattern for destructuring
Circle(radius: var r) => area(r),
❌ Don’t Do This:
// Missing case in exhaustive switch
// var x = switch (status) { Status.pending => '...' }; // ❌ error
// Using default when exhaustiveness is possible
// switch (status) { default: ... } // ❌ hides missing cases
// Pattern matching for simple boolean
// if (x is String) vs switch // ❌ overkill for one check
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Non-exhaustive switch error | Missing case for enum/sealed | Add all cases or _ |
| Guard not matching | Condition false; falls to next case | Check guard logic |
| Object pattern fails | Positional fields not supported | Use named getters |
| List pattern length mismatch | Pattern expects exact length | Use ... rest element |
| Sealed subclass error | Subclass in different library | Move to same file |
| Switch expression statement error | Expression at start of statement | Use variable assignment |
Real-World Examples
1. Enum to Label
var label = switch (status) {
Status.pending => 'Pending',
Status.active => 'Active',
Status.closed => 'Closed',
};
2. Type Dispatch
var result = switch (value) {
int n => n * 2,
String s => s.length,
_ => 0,
};
3. Guard for Range
var grade = switch (score) {
>= 90 => 'A',
>= 80 => 'B',
>= 70 => 'C',
_ => 'F',
};
4. Record Destructuring
var (name, age) = ('Alice', 30);
print('$name is $age');
5. List Pattern
var message = switch (list) {
[] => 'empty',
[var x] => 'one: $x',
[var x, ...] => 'first: $x',
};
6. Sealed Class Result
sealed class Result {}
class Success extends Result { final String data; Success(this.data); }
class Failure extends Result { final String error; Failure(this.error); }
String render(Result r) => switch (r) {
Success(data: var d) => d,
Failure(error: var e) => 'Error: $e',
};
7. Object Pattern
var desc = switch (shape) {
Circle(radius: var r) => 'Circle r=$r',
Square(side: var s) => 'Square s=$s',
};
8. Widget Selection
Widget icon = switch (status) {
Status.pending => const CircularProgressIndicator(),
Status.active => const Icon(Icons.check),
Status.closed => const Icon(Icons.close),
};
9. Logical-Or Pattern
var type = switch (char) {
'a' || 'e' || 'i' || 'o' || 'u' => 'vowel',
_ => 'consonant',
};
10. Nested Pattern
var result = switch (value) {
(int x, int y) when x == y => 'equal',
(int x, int y) => 'different',
_ => 'other',
};
Visual
Switch Statement vs Expression
┌──────────────────────────────────────────────────────────────┐
│ STATEMENT EXPRESSION │
│ │
│ switch (status) { var label = switch │
│ case Status.pending: (status) { │
│ print('Waiting'); Status.pending => │
│ case Status.active: 'Waiting', │
│ print('Running'); Status.active => │
│ case Status.closed: 'Running', │
│ print('Done'); Status.closed => │
│ } 'Done', │
│ }; │
│ │
│ Executes statements. Produces a value. │
│ No value. Can appear anywhere │
│ an expression is │
│ expected. │
└──────────────────────────────────────────────────────────────┘
Pattern Matching Capabilities
┌──────────────────────────────────────────────────────────────┐
│ PATTERN TYPES IN SWITCH CASES │
│ │
│ Constant: case 100: │
│ Type: int n => │
│ List: [var a, var b] => │
│ Record: (var x, var y) => │
│ Object: Circle(radius: var r) => │
│ Wildcard: _ => │
│ Logical-or: 'a' || 'b' => │
│ Guard: int n when n > 0 => │
│ │
│ Patterns can nest: Employee(name: var n, coordinates: │
│ (var x, var y)) => ... │
└──────────────────────────────────────────────────────────────┘
Exhaustiveness with Sealed Classes
┌──────────────────────────────────────────────────────────────┐
│ sealed class Result {} │
│ class Success extends Result { final String data; ... } │
│ class Failure extends Result { final String error; ... } │
│ │
│ String render(Result r) => switch (r) { │
│ Success(data: var d) => d, │
│ Failure(error: var e) => 'Error: $e', │
│ }; │
│ │
│ Compiler knows all direct subtypes because they are in │
│ the same library. Missing a case is a compile error. │
│ │
│ If a new subtype is added, the compiler reports: │
│ "The type 'Result' is not exhaustively matched..." │
└──────────────────────────────────────────────────────────────┘
Destructuring in Patterns
┌──────────────────────────────────────────────────────────────┐
│ RECORD DESTRUCTURING │
│ │
│ var point = (2, 3); │
│ var (x, y) = point; │
│ // x = 2, y = 3 │
│ │
│ IN SWITCH: │
│ var quadrant = switch (point) { │
│ (0, 0) => 'Origin', │
│ (var x, var y) => '($x, $y)', │
│ }; │
│ │
│ LIST DESTRUCTURING │
│ │
│ var [a, b, c] = [1, 2, 3]; │
│ // a = 1, b = 2, c = 3 │
│ │
│ OBJECT DESTRUCTURING │
│ │
│ var Circle(radius: r) = circle; │
│ // r = circle.radius │
└──────────────────────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Switch statement | Executes code; no value |
| Switch expression | Produces a value; pattern => expression |
| Case pattern | Constant, type, list, record, object, wildcard, or-or |
| Guard clause | when condition after pattern |
| Exhaustiveness | Enforced for bool, enum, sealed class, records |
| Sealed class | Restricts subclasses to same library |
| Destructuring | Binds parts of matched value to variables |
| Default/wildcard | _ or default for unmatched cases |
| Dart version | Patterns and switch expressions require Dart 3.0+ |
Key takeaways:
- Switch expressions produce values. They can appear anywhere an expression is allowed, including widget trees, return statements, and assignments. The switch statement executes code and produces nothing .
- Cases are patterns, not just constants. A case can match a type, destructure a list, record, or object, and use guard clauses with
when. - Exhaustiveness is enforced for always-exhaustive types. Enums, sealed classes, booleans, and records of exhaustive types require every case to be handled. A missing case is a compile error .
- Sealed classes make exhaustiveness possible. The
sealedmodifier restricts subclasses to the same library, so the compiler knows every possible subtype . - Patterns destructure as they match.
Circle(radius: var r)matches a Circle and binds its radius torin one step . - Guard clauses add conditions to patterns.
int n when n > 0matches only positive integers, letting the same type pattern handle different ranges . - Dart 3 removed the need for break. Non-empty cases do not fall through. Empty cases fall through to share a body .
- The switch expression is the idiomatic replacement for ternaries and if/else chains in Flutter. It selects a value declaratively and the compiler checks that every case is covered .
Remember: Dart 3’s switch is not the C-style switch it used to be. It is a pattern-matching construct with two forms: a statement for side effects and an expression for value selection. Cases are patterns—constants, types, collections, records, objects—and they can destructure the matched value while binding variables. Guard clauses add conditions. Exhaustiveness checking, powered by sealed classes and enums, turns missing cases into compile errors. The switch expression is the feature that most changed how Dart code is written: it eliminated the nested ternaries and helper methods that were previously required to map a value to a widget or a label. When you write a switch over an enum or a sealed type, the compiler verifies that every case is handled. That is a guarantee that no other construct in the language provides.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!