Dart Records and Patterns — Destructuring and Pattern Matching
In this tutorial, you will learn about Dart Records and Patterns. We cover key concepts, practical examples, and best practices to help you master this topic.
Dart records are lightweight anonymous data structures that group multiple values into a single object, while patterns provide destructuring and matching capabilities introduced in Dart 3.
What You Will Learn
- Creating and using records with positional and named fields
- Destructuring records, lists, and maps
- Pattern matching in switch expressions and statements
- If-case patterns for conditional matching
- Guard clauses with when patterns
- Using patterns with collections and JSON
Why It Matters
Records and patterns together transform how you write Dart code. Records eliminate the need to create small wrapper classes just to return multiple values from a function. Patterns make data extraction concise and readable. Switch expressions with pattern matching replace complex if-else chains with declarative, exhaustive matching. These features, introduced in Dart 3, reduce boilerplate and make code more expressive.
Real-World Use
The DodaTech app uses records extensively in API layer code. A function that fetches user data returns a (User?, String?) record representing (data, error), eliminating the need for a separate ApiResult class. Pattern matching in the UI layer destructures API responses and renders the appropriate widget.
Learning Path
flowchart LR A[Dart Extensions] --> B[Records and Patterns\nYou are here] B --> C[Flutter Setup] style B fill:#f90,color:#fff
Creating Records
Records group multiple values without defining a class:
void main() {
// Positional record
var person = ('Alice', 30, true);
print('${person.$1} is ${person.$2} years old, active: ${person.$3}');
// Named record
var book = (title: 'Dart Guide', pages: 350, published: true);
print('${book.title} has ${book.pages} pages');
// Mixed positional and named
var mixed = (42, label: 'answer', true);
print('${mixed.\$1}, ${mixed.label}, ${mixed.\$3}');
// Single field records
var single = (42); // Just a parenthesized int, NOT a record
var actualRecord = (42,); // Trailing comma makes it a record
print('${single.runtimeType} vs ${actualRecord.runtimeType}');
}
Output:
Alice is 30 years old, active: true
Dart Guide has 350 pages
42, answer, true
int vs (int)
Positional fields are accessed with $1, $2, $3. Named fields are accessed by name. A trailing comma after the first field distinguishes a single-field record from a parenthesized expression.
Records as Return Types
Records are commonly used to return multiple values from functions:
// Function returning multiple values without a custom class
(String, int, double) analyzeList(List<int> numbers) {
if (numbers.isEmpty) return ('empty', 0, 0.0);
var sum = numbers.fold(0, (a, b) => a + b);
var avg = sum / numbers.length;
var status = sum > 100 ? 'large' : 'small';
return (status, sum, avg);
}
void main() {
var data = [10, 20, 30, 40, 50];
var (status, sum, avg) = analyzeList(data);
print('Status: $status');
print('Sum: $sum');
print('Average: $avg');
}
Output:
Status: small
Sum: 150
Average: 30.0
Destructuring with var (field1, field2, ...) = function() extracts the record fields into separate variables in one line.
Named Records for Clarity
Named records make the meaning of each field clear at the call site:
// Named record type
({String name, int age, String? email}) createUser(String name, int age) {
return (name: name, age: age, email: null);
}
({double min, double max, double average}) computeStats(List<double> values) {
if (values.isEmpty) return (min: 0, max: 0, average: 0.0);
var min = values.reduce((a, b) => a < b ? a : b);
var max = values.reduce((a, b) => a > b ? a : b);
var avg = values.fold(0.0, (a, b) => a + b) / values.length;
return (min: min, max: max, average: avg);
}
void main() {
var user = createUser('Alice', 30);
print('User: ${user.name}, ${user.age}, email: ${user.email}');
var temps = [23.5, 25.1, 22.8, 26.3, 21.9];
var stats = computeStats(temps);
print('Temp range: ${stats.min} - ${stats.max}, avg: ${stats.average}');
}
Output:
User: Alice, 30, email: null
Temp range: 21.9 - 26.3, avg: 23.92
Named records solve the "what does field $1 mean?" problem. The field names are part of the type, so the compiler enforces correct usage.
Pattern Matching on Records
Patterns destructure records in assignments and switch cases:
void main() {
var point = (3, 4);
// Destructuring in variable declaration
var (x, y) = point;
print('x: $x, y: $y');
// Destructuring in switch
var description = switch (point) {
(0, 0) => 'Origin',
(0, _) => 'On Y-axis',
(_, 0) => 'On X-axis',
var (px, py) when px == py => 'On diagonal',
var (px, py) => 'Point at ($px, $py)',
};
print(description);
// Nested records
var nested = ('Alice', (30, 'Engineer'));
var (name, (age, job)) = nested;
print('$name is $age and works as $job');
}
Output:
x: 3, y: 4
Point at (3, 4)
Alice is 30 and works as Engineer
Patterns destructure at multiple levels. The _ wildcard matches any value. The var keyword declares new variables. The when clause adds a condition.
List and Map Patterns
Patterns also destructure lists and maps:
void main() {
// List patterns
var list1 = [1, 2, 3];
var list2 = [1, 2, 3, 4, 5];
void describeList(List<int> list) {
var description = switch (list) {
[] => 'Empty',
[var a] => 'Single element: $a',
[var a, var b] => 'Two elements: $a, $b',
[var a, var b, var c] => 'Three elements: $a, $b, $c',
[var a, ..., var b] => 'First: $a, Last: $b, Length: ${list.length}',
};
print(description);
}
describeList([]);
describeList([42]);
describeList([1, 2]);
describeList([1, 2, 3]);
describeList([1, 2, 3, 4, 5]);
// Map patterns
var json = {'name': 'Alice', 'age': 30, 'role': 'admin'};
if (json case {'name': var name, 'age': var age}) {
print('Extracted: $name, $age years old');
}
}
Output:
Empty
Single element: 42
Two elements: 1, 2
Three elements: 1, 2, 3
First: 1, Last: 5, Length: 5
Extracted: Alice, 30 years old
The ... rest element matches zero or more elements in the middle of a list. Map patterns match specific keys and extract their values.
If-Case Patterns
The if-case construct matches a single pattern without a full switch:
void main() {
Object value = 'Hello, Dart!';
// if-case with type check
if (value is String) {
print('String: ${value.toUpperCase()}');
}
// if-case with destructuring
var point = (5, 12);
if (point case (var x, var y) when x > 0 && y > 0) {
print('First quadrant point at ($x, $y)');
}
// if-case on nullable
String? maybeName = 'Bob';
if (maybeName case var name?) {
print('Name is: $name');
}
// if-case with relational patterns
var age = 25;
if (age case > 18) {
print('Adult');
}
}
Output:
String: HELLO, DART!
First quadrant point at (5, 12)
Name is: Bob
Adult
If-case is ideal when you need to match one specific pattern. It avoids the boilerplate of a full switch when only one case is relevant.
Relational and Logical Patterns
Dart 3 supports relational operators and logical combinators in patterns:
void main() {
String classifyNumber(int n) {
return switch (n) {
> 0 && < 10 => 'Single digit positive',
>= 10 && < 100 => 'Two digit positive',
>= 100 => 'Large positive',
< 0 && > -10 => 'Single digit negative',
< -10 && > -100 => 'Two digit negative',
0 => 'Zero',
_ => 'Other',
};
}
print(classifyNumber(5));
print(classifyNumber(42));
print(classifyNumber(-7));
print(classifyNumber(0));
print(classifyNumber(1000));
// OR patterns with ||
String describeColor(String color) {
return switch (color) {
'red' || 'green' || 'blue' => 'Primary color',
'cyan' || 'magenta' || 'yellow' => 'Secondary color',
_ => 'Unknown color',
};
}
print(describeColor('red'));
print(describeColor('cyan'));
print(describeColor('purple'));
}
Output:
Single digit positive
Two digit positive
Single digit negative
Zero
Large positive
Primary color
Secondary color
Unknown color
Relational patterns use >, <, >=, <= operators. Logical patterns use && and || to combine conditions. These make switch statements as expressive as if-else chains.
Pattern Matching Types
Switch on types with pattern matching:
sealed class Animal {}
class Dog extends Animal {
final String name;
Dog(this.name);
}
class Cat extends Animal {
final String name;
Cat(this.name);
}
class Bird extends Animal {
final String species;
Bird(this.species);
}
String describeAnimal(Animal animal) {
return switch (animal) {
Dog(name: var n) => 'Dog: $n',
Cat(name: var n) => 'Cat: $n',
Bird(species: var s) => 'Bird: $s',
};
}
void main() {
var animals = [Dog('Buddy'), Cat('Whiskers'), Bird('Parrot')];
for (var animal in animals) {
print(describeAnimal(animal));
}
}
Output:
Dog: Buddy
Cat: Whiskers
Cat: Whiskers
Type patterns in switch match the runtime type and destructure the object simultaneously. The sealed class ensures exhaustive matching: the compiler checks that every subclass has a case.
Patterns with Null Safety
Patterns integrate with null safety for concise null handling:
void main() {
String? maybeString = 'Hello';
// Pattern matching on nullable
var result = switch (maybeString) {
null => 'Got null',
var s => 'String: $s',
};
print(result);
// Null-check pattern
if (maybeString case var s?) {
print('Not null: $s');
}
// Null-value extraction with records
(String?, int?) parseInput(String input) {
var parts = input.split(',');
var name = parts.length > 0 ? parts[0].trim() : null;
var age = parts.length > 1 ? int.tryParse(parts[1].trim()) : null;
return (name, age);
}
var (name, age) = parseInput('Alice, 30');
print('${name ?? 'Unknown'}, ${age?.toString() ?? 'Unknown age'}');
}
Output:
String: Hello
Not null: Hello
Alice, 30
Null-check patterns (var s?) match non-null values and bind the unwrapped value. Patterns with nullable records extract only the non-null fields.
Common Mistakes
Forgetting the trailing comma for single-field records:
var record = (42);creates an int, not a record. Usevar record = (42,);with a trailing comma.Confusing record field access with class fields: Record fields are accessed as
$1,$2for positional or by name for named. They are not class instances.Not handling all cases in exhaustive switch: When switching on a sealed class, every subtype must have a case. Add a
_ =>default or handle all variants.Over-nesting patterns: Deeply nested patterns (more than 2-3 levels) can be hard to read. Extract intermediate results into variables for clarity.
Using positional records without considering readability: When returning records from functions, consider named fields for better documentation at the call site.
Practice Questions
- What is the difference between
(int, String)and{int x, String y}records? - How does
if-casediffer fromswitchwith a single case? - How do relational patterns (
>,<) improve switch expressions? - Why are records preferred over creating small wrapper classes for return values?
- Challenge: Write a function that takes a JSON-like
Map<String, dynamic>and uses patterns to extract and validate user data. Return a record of(User?, List<String>)where the first field is the parsed user and the second is a list of validation errors.
Mini Project
Build a configuration parser with records and patterns:
- Define a sealed class
ConfigValuewithStringValue,IntValue,BoolValue,ListValuesubtypes - Parse a
Map<String, dynamic>intoMap<String, ConfigValue> - Use pattern matching to validate each value type
- Use records to return Parsing results with accumulated errors
- Implement a
getValue<T>function that uses type patterns
FAQ
What is Next
Now that you understand records and patterns, set up Flutter. Proceed to Flutter Setup Guide for Flutter SDK installation and first project. Then explore Flutter Widgets for building UI with the widget framework.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro