Everything you need to know about Patterns, Records, and Switch expressions — explained simply with real Flutter examples.
Table of Contents
What are Patterns?
Before Dart 3, extracting data from complex objects required multiple lines of manual work. It was repetitive and error-prone.
Think of a Pattern like a shape stencil. Hold it against some data — if the data fits the shape, you extract pieces right there inline. If it doesn't match, the pattern fails.
Official definition: Patterns are a syntactic category in Dart, like statements and expressions. A pattern represents the shape of a set of values that it may match against actual values.
Patterns do two things:
- Match — check whether a value has a certain structure or content
- Destructure — pull out parts of a value into separate variables
Key takeaway: Patterns are not just a syntax trick. They fundamentally change how you write conditional logic — more expressive, safer, and often much shorter.
Variable Patterns
A variable pattern binds a matched value to a new variable. It is the building block of every other pattern. Use final (preferred) or var.
// Typed variable — only matches if value IS that type
switch (response) {
case final String message:
print('Got a string: $message');
case final int number:
print('Got a number: $number');
case final List<String> items:
print('Got ${items.length} items');
}
// Typed final in if-case
if (getAnything() case final String text) {
// only enters if getAnything() returned a String
print(text.toUpperCase());
}
// var — can reassign (rarely needed)
if (getValue() case var result) {
result = result.toString().toUpperCase();
print(result);
}
Key takeaway: Always prefer final over var in patterns. Think of it as: match it, bind it, use it — don't change it.
if-case Statement
The if-case lets you match a pattern and bind variables inside a single if condition — without a full switch block. “If this value matches this shape, enter the block with these bound variables.”
// Two-step: store then check
final result = fetchUser();
if (result != null) {
print(result.name);
}
// Calls getMap() TWICE
if (getMap().containsKey('x')) {
final val = getMap()['x'];
print(val);
}
// Match, bind, use — one step
if (fetchUser() case final user) {
print(user.name);
}
// Called ONCE, result bound
if (getMap() case final m
when m.containsKey('x')) {
print(m['x']);
}
dynamic apiResponse = fetchData();
// Only enters if apiResponse IS a Map
if (apiResponse case final Map<String, dynamic> data) {
print(data['name']); // typed as Map inside
}
// Only enters if it IS a String
if (apiResponse case final String error) {
showError(error);
}
// Type AND condition together
if (apiResponse case final int code when code > 400) {
showHttpError(code);
}
Key takeaway: Use if-case when you have a single pattern. It replaces: “call function → store in temp var → check temp var.” Now it's one line.
Guard Clauses (when)
A pattern checks structure and type. The when keyword adds an extra value condition on top. Both must pass for the block to execute.
// when in if-case
if (getScore() case final int score when score >= 90) {
print('Grade A: $score');
}
// when in switch statement
switch (user) {
case final User u when u.isAdmin && u.isActive:
grantAdminAccess(u);
case final User u when u.isActive:
grantBasicAccess(u);
case _:
denyAccess();
}
// when in switch expression
String label = switch (temperature) {
final t when t < 0 => 'Freezing',
final t when t < 15 => 'Cold',
final t when t < 25 => 'Comfortable',
_ => 'Hot',
};
Important: The when clause can only use variables bound by the pattern. External variables belong in a regular if after the block.
Key takeaway: Pattern = shape check. When = value check. Without when, you'd need a nested if inside the block.
Switch Expressions
Before Dart 3, switch was a statement — it ran code but produced no value. The new switch expression returns a value, perfect for final x = switch (...) { ... }.
String label;
switch (status) {
case 'active':
label = 'Active';
break;
case 'inactive':
label = 'Inactive';
break;
default:
label = 'Unknown';
}
// One line, label is final
final label = switch (status) {
'active' => 'Active',
'inactive' => 'Inactive',
_ => 'Unknown',
};
// With type patterns
Widget buildIcon(dynamic v) => switch (v) {
final String s => Text(s),
final int i => Text(i.toString()),
final IconData icon => Icon(icon),
_ => const SizedBox.shrink(),
};
// With guard clauses
Color statusColor(int code) => switch (code) {
final c when c >= 200 && c < 300 => Colors.green,
final c when c >= 400 && c < 500 => Colors.orange,
final c when c >= 500 => Colors.red,
_ => Colors.grey,
};
// Returning a Record (multiple values at once)
(String, Color) getInfo(int code) => switch (code) {
200 => ('OK', Colors.green),
404 => ('Not Found', Colors.orange),
500 => ('Server Error', Colors.red),
_ => ('Unknown', Colors.grey),
};
Key takeaway: Switch expressions replace long if-else chains for value mapping — status codes to colors, enum cases to widgets, API states to UI states.
Switch Statements with Patterns
The old switch only matched exact values. In Dart 3, cases can use full patterns — type checks, destructuring, guards, everything.
// Pattern matching on type
void handleResponse(dynamic response) {
switch (response) {
case final Map<String, dynamic> data:
print('Map: ${data.length} keys');
case final List<dynamic> list:
print('List: ${list.length} items');
case final String text:
print('String: $text');
case null:
print('Got null');
default:
print('Unknown type');
}
}
// Exhaustive switch on sealed class — no default needed!
sealed class Shape {}
class Circle extends Shape { final double radius; Circle(this.radius); }
class Rectangle extends Shape { final double w, h; Rectangle(this.w, this.h); }
class Triangle extends Shape { final double b, h; Triangle(this.b, this.h); }
double area(Shape s) => switch (s) {
Circle(:final radius) => 3.14 * radius * radius,
Rectangle(:final w, :final h) => w * h,
Triangle(:final b, :final h) => 0.5 * b * h,
};
Sealed classes + switch = exhaustive checking. When you switch on a sealed class, Dart verifies all subclasses are handled. Add a new subclass and forget the switch — it's a compile error, not a runtime crash.
Key takeaway: No more if (x is Circle) { final c = x as Circle; ... }. The pattern checks the type, casts, and extracts fields in a single case.
Record Types
Records are Dart 3's new built-in type for grouping multiple values together without defining a class. Anonymous, immutable, typed tuples.
// Overkill: dedicated class
class NameAge {
final String name;
final int age;
NameAge(this.name, this.age);
}
// Not type-safe: Map
Map<String, dynamic> getUser() =>
{'name': 'Ali', 'age': 25};
// caller must cast: map['age'] as int
// Named record — zero boilerplate
({String name, int age}) getUser() =>
(name: 'Ali', age: 25);
final u = getUser();
print(u.name); // typed — no cast!
print(u.age);
// Positional — access with .$1, .$2
(String, int) pos = ('Flutter', 3);
print(pos.$1); // Flutter
print(pos.$2); // 3
// Named — access with .fieldName
({String lang, int version}) named = (lang: 'Flutter', version: 3);
print(named.lang); // Flutter
print(named.version); // 3
// Mixed
(int, {String label}) mixed = (42, label: 'Answer');
print(mixed.$1); // 42
print(mixed.label); // Answer
// Built-in value equality
print(('a', 1) == ('a', 1)); // true
print((name: 'x') == (name: 'x')); // true
Key takeaway: Use Records when returning 2–3 related values. Built-in equality, fully type-safe, zero boilerplate. Don't create a class just to return two things.
Record Destructuring
Destructuring extracts record fields into separate named variables in a single line.
// Positional record
(String, int) getCoords() => ('London', 51);
final (city, latitude) = getCoords();
print(city); // London
print(latitude); // 51
// Named record
({String name, int age, bool isAdmin}) getUser() =>
(name: 'Sara', age: 30, isAdmin: true);
final (:name, :age, :isAdmin) = getUser();
print(name); // Sara
print(isAdmin); // true
// In a for loop
final users = [(name: 'Alice', score: 95), (name: 'Bob', score: 82)];
for (final (:name, :score) in users) {
print('$name scored $score');
}
// Inside if-case
if (fetchStatus() case (final int code, final String msg)
when code == 200) {
print('Success: $msg');
}
Key takeaway: Destructure once at the top instead of repeating .fieldName everywhere. Cleaner, less repetition.
Object Patterns
Object patterns match against a specific class type and destructure its fields — all in one step.
class User {
final String name;
final int age;
User(this.name, this.age);
}
// Match type + extract fields in one case
void greet(Object obj) {
switch (obj) {
case User(:final name, :final age) when age >= 18:
print('Hello adult $name!');
case User(:final name, :final age):
print('Hello $name, you are $age');
default:
print('Not a user');
}
}
// :final name = shorthand for name: final name
// (extract 'name' field into a var also called 'name')
// Rename if needed
case User(name: final displayName, age: final userAge):
print('$displayName is $userAge years old');
// Nested object patterns
switch (order) {
case Order(
customer: User(:final name),
total: final t,
) when t > 1000:
print('High-value order from $name: $$t');
}
Key takeaway: Replaces if (x is Foo) { final f = x as Foo; ... } completely. Type check + cast + field extraction in a single case.
List & Map Patterns
List patterns
// Exactly 2 elements
if (coords case [final lat, final lng]) {
print('Lat: $lat, Lng: $lng');
}
// First element + rest
if (items case [final first, ...final rest]) {
print('First: $first, remaining: ${rest.length}');
}
// Match by length / structure
switch (args) {
case []: print('No arguments');
case [final s]: print('One: $s');
case [final a, final b]: print('Two: $a and $b');
case [final f, ...]: print('Many, first: $f');
}
Map patterns
// Match specific keys
if (json case {'name': final String name, 'role': 'admin'}) {
print('Admin: $name');
}
// API response parsing
void handle(Map<String, dynamic> r) {
switch (r) {
case {'status': 'success', 'data': final Map data}:
processData(data);
case {'status': 'error', 'message': final String msg}:
showError(msg);
case {'status': 'pending'}:
showLoader();
default:
showGenericError();
}
}
Key takeaway: Map patterns check that keys exist AND match a value pattern. Extra keys are ignored — perfect for parsing JSON.
Logical Patterns (&&, ||)
Combine patterns with && (and) or || (or) to create composite patterns without nesting.
// OR — same body for multiple cases (replaces fallthrough)
String classify(int n) => switch (n) {
1 || 2 || 3 => 'small',
4 || 5 || 6 => 'medium',
7 || 8 || 9 => 'large',
_ => 'out of range',
};
// OR — match either type
switch (shape) {
case Circle() || Square(): print('Simple');
case Triangle() || Hexagon(): print('Complex');
}
// OR with when
switch (user) {
case Admin() || Moderator() when user.isActive:
showManagementPanel(user);
case RegularUser() when user.isActive:
showUserDashboard(user);
default:
showInactiveScreen();
}
Key takeaway: Use || to handle multiple cases with one outcome (replaces stacked case labels). Use && when a value must satisfy two patterns simultaneously.
Real-world Flutter / Bloc Examples
1. if-case: call a function only once
// Called TWICE
} else if (_checkDuplicates()
.values.first) {
int id = _checkDuplicates()
.keys.first;
return getError(id);
}
// Called ONCE, bound to 'dup'
} else if (_checkDuplicates()
case final dup
when dup.values.first) {
int id = dup.keys.first;
return getError(id);
}
2. Switch expression: BLoC state → Widget
sealed class NomineeState {}
class NomineeLoading extends NomineeState {}
class NomineeLoaded extends NomineeState {
final List<Nominee> nominees;
NomineeLoaded(this.nominees);
}
class NomineeError extends NomineeState {
final String message;
NomineeError(this.message);
}
Widget buildContent(NomineeState state) => switch (state) {
NomineeLoading() => const CircularProgressIndicator(),
NomineeLoaded(:final nominees)
when nominees.isEmpty => const EmptyNomineeView(),
NomineeLoaded(:final nominees) => NomineeListView(nominees: nominees),
NomineeError(:final message) => ErrorView(message: message),
};
3. Record: return multiple values from a method
({bool isValid, String? error}) _validate(Nominee n) {
if (n.name.isEmpty) return (isValid: false, error: 'Name required');
if (n.share <= 0) return (isValid: false, error: 'Share must be positive');
return (isValid: true, error: null);
}
// Destructure the result
final (:isValid, :error) = _validate(nominee);
if (!isValid) {
emit(state.copyWith(errorMessage: error));
return;
}
4. Map pattern: parse JSON API response
void processResponse(Map<String, dynamic> json) {
switch (json) {
case {'status': 'success',
'data': {'nominees': final List nominees}}:
emit(NomineeLoaded(nominees.map(Nominee.fromJson).toList()));
case {'status': 'error', 'message': final String msg}:
emit(NomineeError(msg));
case {'status': 'token_expired'}:
emit(NomineeSessionExpired());
default:
emit(NomineeError('Unexpected response'));
}
}
5. Quick reference
| Pattern | Syntax | Use when |
|---|---|---|
| Variable | case final x | Bind any value |
| Typed variable | case final String x | Check type AND bind |
| if-case | if (expr case pat) | Single inline pattern check |
| Guard | case pat when cond | Extra condition on pattern |
| Switch expression | final x = switch(v){} | Map value to another value |
| Record | (String, int) | Return multiple typed values |
| Record destructure | final (:a, :b) = rec | Extract record fields inline |
| Object pattern | case Foo(:final x) | Type check + field extraction |
| List pattern | case [a, b, ...rest] | Match list structure |
| Map pattern | case {'k': final v} | Match map keys/values |
| Logical OR | case A() || B() | Match either pattern |
| Logical AND | case A() && B() | Must match both patterns |
Final takeaway: Start with if-case and switch expressions — easiest wins. Then adopt Records for multi-value returns, and object patterns for sealed class hierarchies.
Written to help Flutter developers adopt Dart 3 Patterns confidently — with real examples from production code.
Dart 3.0+ · Flutter 3.10+ · All examples tested

0 Comments