Records and Pattern Matching
Dart 3 added two features that work together: records, which are anonymous product types, and patterns, which take them apart. Between them they remove a large amount of the ceremony that used to surround "return two things" and "branch on the shape of this value".
Records
A record is a fixed-size, immutable bundle of values with its own type. No class declaration, and equality comes free.
(int, int) minMax(List<int> values) {
var lo = values.first, hi = values.first;
for (final v in values) {
if (v < lo) lo = v;
if (v > hi) hi = v;
}
return (lo, hi);
}
// Named fields make the meaning obvious at the call site.
({String name, int age}) parse(String line) {
final parts = line.split(',');
return (name: parts[0], age: int.parse(parts[1]));
}
void main() {
final range = minMax([5, 2, 9, 1]);
print(range);
print('${range.$1} to ${range.$2}'); // positional fields are $1, $2, ...
final person = parse('Ada,36');
print('${person.name} is ${person.age}');
// Structural equality: two records with equal fields are equal.
print((1, 'a') == (1, 'a'));
print(minMax([5, 2, 9, 1]) == (1, 9));
}
Use a record when the grouping is local and obvious. Use a class when the thing has a name, a meaning, or any behaviour of its own.
Destructuring
A pattern on the left of = pulls a value apart.
void main() {
final (lo, hi) = (1, 9);
print('$lo..$hi');
final (name: who, age: years) = (name: 'Ada', age: 36);
print('$who $years');
// Lists and maps destructure too.
final [first, second, ...rest] = [1, 2, 3, 4, 5];
print('$first $second $rest');
final {'host': host, 'port': port} = {'host': 'localhost', 'port': 8080};
print('$host:$port');
// Swapping without a temporary.
var a = 'left', b = 'right';
(a, b) = (b, a);
print('$a $b');
// In a for loop, which is what makes map iteration read well.
final scores = {'ada': 10, 'grace': 12};
for (final MapEntry(:key, :value) in scores.entries) {
print('$key scored $value');
}
}
switch on shape, with guards
A pattern in a switch matches structure, binds the parts, and can add a
condition with when.
String describe(Object value) => switch (value) {
0 => 'zero',
int n when n < 0 => 'negative $n',
int n => 'positive $n',
String s when s.isEmpty => 'an empty string',
String s => 'a string of ${s.length}',
[int a, int b] => 'a pair adding to ${a + b}',
[_, ...] => 'some other list',
{'type': final String type} => 'a map of type $type',
_ => 'something else',
};
void main() {
for (final value in <Object>[
0, -3, 7, '', 'hello', [2, 3], [1, 2, 3], {'type': 'user'}, 3.5,
]) {
print('$value -> ${describe(value)}');
}
}
if-case, for the single branch that matters
void main() {
final responses = <Object>[
{'status': 200, 'body': 'ok'},
{'status': 404},
'not a map at all',
];
for (final response in responses) {
if (response case {'status': final int status, 'body': final String body}) {
print('$status with body "$body"');
} else if (response case {'status': final int status}) {
print('$status with no body');
} else {
print('unrecognised: $response');
}
}
}
Sealed classes plus patterns: states you cannot forget
This is where the two features earn their place. A sealed class lists the possible shapes; an exhaustive switch forces every one to be handled; adding a new shape breaks compilation everywhere it matters, which is exactly when you want to hear about it.
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 message;
const Failure(this.message);
}
class Pending<T> extends Result<T> {
const Pending();
}
String render(Result<int> result) => switch (result) {
Success(:final value) => 'value $value',
Failure(:final message) => 'failed: $message',
Pending() => 'still working',
};
void main() {
const results = <Result<int>>[
Success(42),
Failure('timeout'),
Pending(),
];
results.map(render).forEach(print);
}
Delete the Pending() branch and the code stops compiling, with the analyzer
naming the type you missed. That is the trade: a little more structure up front,
in exchange for the compiler carrying the list of states for you.
What to take away
- A record groups values without declaring a class, and compares by value.
$1,$2for positional fields; named fields read better at a call site.- Destructuring works in assignments,
forloops,switchandif case. whenadds a condition to a pattern;_matches anything.sealedplusswitchgives exhaustiveness, so a new state is a compile error.- Records for local groupings, classes for things with a name and behaviour.