Dart / Contracts: patterns, generic APIs, and asynchronous results / Lesson 7
Use records and patterns to preserve data shape
Return multiple values with records and match known shapes through destructuring, type patterns, guards, and switch expressions.
On this page
Lesson goals
- Explain a record’s structural shape and distinguish immutable fields from mutable referenced objects.
- Choose a pattern that checks the intended condition without treating destructuring as complete validation.
A record groups values without defining a new class
Records are fixed-size compound values with positional fields, named fields, or both. Their types are structural: the number and types of positional fields and the names and types of named fields determine the shape. Named-field order does not create a different record type, but changing a named field’s name changes the shape. A typedef can give a useful alias without introducing a new nominal identity. Use a record for a small related result; choose a class when the domain needs controlled construction, behavior, or a stable named abstraction rather than an anonymous bundle.
Return a coherent summary instead of parallel variables
The function below counts positive entries as accepted and every other supplied integer as rejected. It returns a named record so the meaning of each count is visible at the boundary. Destructuring binds those fields to local variables without a sequence of index assumptions. The example requires Dart language support for records and patterns, introduced with Dart 3; a project must declare an SDK language version that supports the syntax. No analyzer or Dart runtime was available during authoring, so the displayed output is a reasoned expectation rather than an observed test result.
({int accepted, int rejected}) summarize(List<int> values) {
final accepted = values.where((value) => value > 0).length;
return (accepted: accepted, rejected: values.length - accepted);
}
void main() {
final summary = summarize(<int>[2, 0, -1, 3]);
final (accepted: good, rejected: bad) = summary;
print('accepted=$good');
print('rejected=$bad');
}
accepted=2
rejected=2
Expected output is accepted=2 then rejected=2. The record labels preserve the two meanings, and the local names good and bad are chosen by the consumer. This source was not compiled.
Patterns can both test and bind
A pattern can check a value’s type or shape and bind components for use in a successful branch. An if-case is useful when a mismatch should simply take another path; a declaration pattern instead assumes a matching shape at that declaration boundary. List and map patterns express particular structural requirements, not every business rule inside the values. A type pattern that binds an int does not establish that it is positive, within an application limit, or authorized for the caller. Keep those semantic checks explicit with a guard or a later validation stage.
A guard refines a successful match
A switch expression produces a value and must cover the possible input space. A when guard is checked after its pattern matches, so the bound variable is available for the condition. Branch order matters when patterns overlap. The example accepts a positive int first, handles other ints separately, and then covers everything else with a wildcard. The final case includes null and strings, so it does not claim that all nonpositive values have the same type. Avoid an early broad wildcard that makes the more specific policy unreachable or hides an added alternative.
String classify(Object? value) => switch (value) {
int count when count > 0 => 'positive:$count',
int _ => 'nonpositive',
_ => 'not-an-int',
};
void main() {
print(classify(3));
print(classify(0));
print(classify('3'));
}
positive:3
nonpositive
not-an-int
Expected output is positive:3, nonpositive, not-an-int. The text value is not parsed implicitly. Dart 3 pattern and switch-expression support is required; no execution is claimed.
Record equality does not deep-freeze a model
Record fields cannot be reassigned, and record equality uses matching shapes and the corresponding field equality contracts. A field can still reference a mutable list whose contents change, and that list’s equality is not automatically a recursive comparison of its elements. Adding a field changes the record contract and can break callers that destructure the old shape. For public APIs, consider whether a class is a clearer evolution boundary. Patterns improve local clarity, but external bytes still need decoding, size limits, type validation, and an explicit policy for unknown fields or values.
Common errors and fixes
A record field is renamed as though it were only a local label.
- Why it happens
- Named field names are part of the structural shape.
- How to fix it
- Treat the rename as a caller-facing contract change.
- Prevent it next time
- Check destructuring and return types before evolving a public record.
A matched integer is accepted despite violating a range rule.
- Why it happens
- Type matching was mistaken for domain validation.
- How to fix it
- Add an explicit guard or validated conversion for the range.
- Prevent it next time
- Test type mismatch and semantic rejection as separate cases.
A record with a list is described as deeply immutable.
- Why it happens
- Fixed fields were confused with immutable referenced objects.
- How to fix it
- Use immutable element models or an explicit snapshot policy.
- Prevent it next time
- Inspect nested aliases before promising stable record content.
Check your understanding
1. Does a map pattern containing id and count prove that an external batch payload is fully valid?
Show worked solution
No. It checks the structural and type conditions expressed by that pattern. The identifier can still be blank or unauthorized, the count can violate the permitted range, and extra fields or size limits may require policy. Decode and validate each required meaning explicitly, then construct a trusted local representation. Do not widen a pattern’s demonstrated claim beyond the conditions it actually states.
2. When is a named class preferable to returning a larger record with many loosely related fields?
Show worked solution
When controlled construction, behavior, versioned meaning, or a stable public abstraction matters. A class can name the responsibility and restrict invalid combinations through its API. A record remains useful for a small structural result, but adding more fields is not automatically a good substitute for modeling a coherent concept. Review actual caller changes and validation needs before choosing.
Key takeaways
- Patterns reveal stated shape and type conditions; guards express additional policy.
- Records are structural values whose nested objects keep their own contracts.