Patterns in Dart — Destructuring and Matching in One
Dart 3 introduced patterns — a way to destructure data and match its shape at the same time. They make switch statements powerful, if-case expressive, and variable declarations cleaner.
What are patterns?
Before Dart 3, we had two separate tools. To pull values out of a structure, we wrote multiple assignment statements. To check if data matched a certain shape, we wrote chains of if-else conditions. Both felt verbose.
Patterns solve both problems at once. A pattern does two things:
1. Destructuring — extracting values from data structures
2. Matching — checking if data has a certain shape
// Before patterns
var list = [1, 2, 3];
var first = list[0];
var second = list[1];
// With patterns — destructure in one line
var [a, b, c] = [1, 2, 3];
print(a); // 1
print(b); // 2
The syntax might look unfamiliar at first, but the idea is simple: we write a shape on the left side, and if the data on the right matches, we get variables bound to the values inside. If it doesn't match, we either get an error (in declarations) or move to the next case (in switch/if-case).
Variable patterns and destructuring
The most basic pattern is just a variable name. It matches anything and binds the value to that name.
var x = 42; // x matches anything, binds to 42
final y = 'hello'; // y matches anything, binds to 'hello'
We've been using this all along — it's just variable declaration. But patterns let us go deeper.
List patterns destructure lists.
var numbers = [1, 2, 3];
var [a, b, c] = numbers;
print('$a, $b, $c'); // 1, 2, 3
// With type annotations
var [int x, int y, int z] = [10, 20, 30];
Record patterns destructure records (Dart 3's lightweight data structures).
var point = (3, 4);
var (x, y) = point;
print('x=$x, y=$y'); // x=3, y=4
// Named fields in records
var user = (name: 'Ankit', age: 25);
var (name: n, age: a) = user;
print('$n is $a'); // Ankit is 25
The underscore pattern ignores a position.
var [first, _, third] = [1, 2, 3];
print(first); // 1
print(third); // 3
// We don't care about the middle value
The rest pattern (...) collects remaining elements.
var [first, ...rest] = [1, 2, 3, 4, 5];
print(first); // 1
print(rest); // [2, 3, 4, 5]
var [head, ...middle, tail] = [1, 2, 3, 4, 5];
print(head); // 1
print(middle); // [2, 3, 4]
print(tail); // 5
Object patterns — matching class properties
Object patterns let us match against the properties of a class instance. The syntax uses the class name followed by the properties we want to extract.
class Point {
final int x;
final int y;
Point(this.x, this.y);
}
void main() {
var point = Point(3, 4);
// Object pattern — extract x and y
var Point(x: px, y: py) = point;
print('$px, $py'); // 3, 4
// If the variable name matches the property, use shorthand
var Point(:x, :y) = point;
print('$x, $y'); // 3, 4
}
The :x shorthand is equivalent to x: x — it binds the property x to a variable also called x.
In switch statements, object patterns are even more powerful. We can match and extract in one step.
void describe(Object obj) {
switch (obj) {
case Point(x: 0, y: 0):
print('Origin');
case Point(:var x, :var y) when x == y:
print('On the diagonal at $x');
case Point(:var x, :var y):
print('Point at ($x, $y)');
default:
print('Not a point');
}
}
describe(Point(0, 0)); // Origin
describe(Point(5, 5)); // On the diagonal at 5
describe(Point(3, 7)); // Point at (3, 7)
List and map patterns
We've seen list patterns for destructuring. They also work for matching in switch and if-case.
void process(List<int> items) {
switch (items) {
case []:
print('Empty list');
case [var single]:
print('Single item: $single');
case [var first, var second]:
print('Pair: $first and $second');
case [var first, ...var rest]:
print('First: $first, rest: $rest');
}
}
process([]); // Empty list
process([42]); // Single item: 42
process([1, 2]); // Pair: 1 and 2
process([1, 2, 3, 4]); // First: 1, rest: [2, 3, 4]
Map patterns match key-value pairs. They're useful for JSON-like data.
void handleResponse(Map<String, dynamic> json) {
switch (json) {
case {'error': var message}:
print('Error: $message');
case {'data': var data, 'count': int count}:
print('Got $count items: $data');
case {'status': 'ok'}:
print('Success with no data');
default:
print('Unknown response format');
}
}
handleResponse({'error': 'Not found'});
// Error: Not found
handleResponse({'data': [1, 2, 3], 'count': 3});
// Got 3 items: [1, 2, 3]
handleResponse({'status': 'ok'});
// Success with no data
Key point: Map patterns only check for the keys we specify. The map can have additional keys — the pattern still matches. This makes them perfect for partial matching of JSON responses where we only care about certain fields.
var json = {'name': 'Ankit', 'age': 25, 'city': 'Delhi'};
// This matches — we only check for 'name'
if (json case {'name': var name}) {
print(name); // Ankit
}
Logical patterns — and, or, parenthesised
Sometimes one pattern isn't enough. Dart lets us combine patterns with logical operators.
Or patterns (||) match if either side matches.
void classifyNumber(int n) {
switch (n) {
case 0 || 1:
print('Binary digit');
case 2 || 3 || 5 || 7:
print('Small prime');
case < 0 || > 100:
print('Out of range');
default:
print('Regular number');
}
}
classifyNumber(1); // Binary digit
classifyNumber(5); // Small prime
classifyNumber(-5); // Out of range
And patterns (&&) require both sides to match. These are especially useful with type checks and guards.
void process(Object value) {
switch (value) {
case int n && > 0:
print('Positive integer: $n');
case String s && != '':
print('Non-empty string: $s');
default:
print('Something else');
}
}
process(42); // Positive integer: 42
process(-5); // Something else (int but not > 0)
process('hello'); // Non-empty string: hello
process(''); // Something else
Parenthesised patterns group patterns to control precedence.
switch (value) {
case (int || double) && > 0:
print('Positive number');
}
Relational patterns are particularly useful for range checking.
String gradeFor(int score) {
return switch (score) {
< 0 || > 100 => 'Invalid',
>= 90 => 'A',
>= 80 => 'B',
>= 70 => 'C',
>= 60 => 'D',
_ => 'F',
};
}
Switch expressions and exhaustiveness
Dart 3 introduced switch expressions — a compact form of switch that returns a value. Combined with patterns, they replace many verbose if-else chains.
// Old switch statement
String describe(int n) {
switch (n) {
case 0:
return 'zero';
case 1:
return 'one';
default:
return 'many';
}
}
// New switch expression
String describe(int n) => switch (n) {
0 => 'zero',
1 => 'one',
_ => 'many',
};
The => replaces case and return. The underscore _ is the wildcard that matches anything.
Exhaustiveness checking. The compiler verifies that switch expressions cover all possible values. This is especially powerful with enums and sealed classes.
enum Status { pending, approved, rejected }
String icon(Status s) => switch (s) {
Status.pending => '...',
Status.approved => 'tick',
Status.rejected => 'cross',
};
// No default needed — compiler knows all cases are covered
Sealed classes unlock the full power. When a class is sealed, its subclasses are known at compile time.
sealed class Result {}
class Success extends Result {
final String data;
Success(this.data);
}
class Failure extends Result {
final String error;
Failure(this.error);
}
String handle(Result r) => switch (r) {
Success(:var data) => 'Got: $data',
Failure(:var error) => 'Error: $error',
};
// Exhaustive — no default needed
If we add a new subclass to Result, the compiler will warn us about every switch that doesn't handle it. No runtime surprises.
The if-case statement uses patterns in a single branch.
var json = {'name': 'Ankit', 'age': 25};
if (json case {'name': String name, 'age': int age}) {
print('$name is $age years old');
} else {
print('Invalid format');
}
This is cleaner than manual type checks and key lookups. The pattern either matches and binds, or the else branch runs.
Guard clauses with when
Sometimes a pattern match isn't enough — we need an additional condition. That's what the when keyword provides.
void process(int n) {
switch (n) {
case var x when x < 0:
print('Negative: $x');
case var x when x == 0:
print('Zero');
case var x when x.isEven:
print('Positive even: $x');
case var x:
print('Positive odd: $x');
}
}
The when clause adds a boolean condition after the pattern. The case only matches if the pattern matches AND the condition is true.
Guards can use destructured values. This is where they shine.
void classifyPoint(Point p) {
switch (p) {
case Point(:var x, :var y) when x == y:
print('On diagonal');
case Point(:var x, :var y) when x == 0:
print('On Y axis');
case Point(:var x, :var y) when y == 0:
print('On X axis');
case Point(:var x, :var y):
print('Somewhere at ($x, $y)');
}
}
In switch expressions:
String describe(int n) => switch (n) {
var x when x < 0 => 'negative',
0 => 'zero',
var x when x < 10 => 'small positive',
var x when x < 100 => 'medium',
_ => 'large',
};
In if-case:
var user = {'name': 'Ankit', 'age': 25, 'role': 'admin'};
if (user case {'name': String name, 'role': 'admin'} when name.isNotEmpty) {
print('Admin: $name');
}
Guards let patterns do the structural matching while conditions handle the value logic. This separation keeps each part simple and readable.
Test your understanding
7 questions
Seven questions covering Dart 3 patterns — destructuring, matching, switch expressions, and guards.