Dart 32 🎯 Records (Dart 3+): Anonymous Type-Safe Aggregates and Destructuring
A function that needs to return two values has always had to choose between two imperfect options: define a class to hold them, or return a collection that loses the types. Dart 3 introduces records, which are anonymous, immutable, aggregate types that bundle multiple values into a single object without the ceremony of a class declaration. A record can hold a String and an int and a DateTime, and the type system tracks each one. The values are accessed by position or by name, and pattern matching destructures them into local variables.
This chapter covers records in full. You will learn the syntax for record expressions and record type annotations, the difference between positional and named fields, how records are accessed and destructured, the equality and subtyping rules that make records value types, and the patterns that use records for function returns, data aggregation, and pattern matching.
Key point: A record is a fixed-size, heterogeneous, immutable aggregate type. The record’s shape is the set of its positional fields and the names of its named fields. Two records with the same shape and equal field values are equal. Records are structurally typed: a record type is a subtype of another record type if they have the same shape and each field type is a subtype of the corresponding field type.
Why records exist
The multi-value return problem. A function that computes a quotient and a remainder needs to return both. A function that parses a document needs to return the title and the modification date. Before records, the options were a class declaration (verbose for a one-off return), a List (loses the element types), or a Map (loses the value types). Records provide a lightweight aggregate that preserves the types of each field.
The type-loss problem. A List<Object> or a Map<String, dynamic> holds heterogeneous values, but the compiler cannot verify what type each element is. Reading from the collection produces Object? or dynamic, and the caller must cast. A record type declares the type of each field, and the compiler checks the access. record.$1 has a static type, and the assignment is verified.
The class-overhead problem. A class is the right tool when the aggregate has behavior, invariants, or a lifecycle. But many aggregates are just a bundle of values: a pair of coordinates, a result and an error, a key and a value. Declaring a class for each one adds a name, a file, and a constructor. A record is anonymous and structural: it does not need a name, and two records with the same shape are the same type.
The destructuring problem. Records are designed to work with patterns. A record can be destructured in a variable declaration, a switch case, or an assignment. The destructuring extracts the fields into local variables, and the pattern can filter on the field values. This is the mechanism that makes records useful for data flow.
The equality problem. A class has identity by default. Two instances with the same field values are not equal unless the class overrides ==. A record has structural equality by default. Two records with the same shape and equal field values are equal, and their hashCode is consistent with equality. This makes records usable as map keys and set elements without custom equality code.
a. Record expressions and type annotations
A record expression is a comma-delimited list of fields enclosed in parentheses. The fields can be positional, named, or both.
var point = (1, 2);
var named = (x: 1, y: 2);
var mixed = (1, 2, z: 3);
The point is a record with two positional fields. The named is a record with two named fields. The mixed has two positional fields and one named field.
The record type annotation mirrors the expression.
(int, int) point = (1, 2);
({int x, int y}) named = (x: 1, y: 2);
(int, int, {int z}) mixed = (1, 2, z: 3);
The (int, int) is the type of the two positional fields. The ({int x, int y}) is the type of the two named fields. The named fields go inside a curly-brace-delimited section after all positional fields .
The names of positional fields in a type annotation are documentation only. They do not affect the type.
(int a, int b) recordAB = (1, 2);
(int x, int y) recordXY = (3, 4);
recordAB = recordXY; // OK, same type
The (int a, int b) and (int x, int y) are the same type. The names a and b are for documentation, like the names of positional parameters in a function type .
The names of named fields are part of the type. Two records with different named field names have different types.
({int a, int b}) recordAB = (a: 1, b: 2);
({int x, int y}) recordXY = (x: 3, y: 4);
// recordAB = recordXY; // Compile error: different types
The ({int a, int b}) and ({int x, int y}) have different shapes, and the shapes are not interchangeable .
A record with a single positional field and no trailing comma is a compile-time error. The parentheses would be ambiguous with a parenthesized expression.
var single = (1,); // OK, trailing comma
// var single = (1); // Error, ambiguous
The trailing comma disambiguates the single-field record from a parenthesized expression .
The empty record () is the type of a record with no fields.
var empty = ();
print(empty); // ()
The () is a valid record expression, and its type is () .
b. Accessing and destructuring record fields
A record’s fields are accessed through built-in getters. Positional fields use $1, $2, and so on. Named fields use their names.
var record = ('first', a: 2, b: true, 'last');
print(record.$1); // 'first'
print(record.a); // 2
print(record.b); // true
print(record.$2); // 'last'
The $1 is the first positional field, and $2 is the second. The named fields a and b are accessed by name. The positional getters skip the named fields and number only the positional ones .
The fields are immutable. There are no setters.
var record = (1, 2);
// record.$1 = 3; // Error: no setter
The immutability is part of the record’s definition. A record is a value, and the value does not change after it is created .
Records are destructured with patterns. A variable declaration pattern extracts the fields into local variables.
var (a, b) = (1, 2);
print(a); // 1
print(b); // 2
var (x, y: z) = (x: 1, y: 2);
print(x); // 1
print(z); // 2
The var (a, b) destructures the positional fields. The var (x, y: z) destructures the named field y into the variable z. The : pattern form with the name omitted infers the name from the variable .
A switch statement destructures the record and matches on the field values.
switch (record) {
case (int value, String name, isValid: bool ok):
print('$value $name $ok');
default:
print('no match');
}
The pattern matches the shape of the record and the types of the fields. If the record does not have the same shape, the match fails .
A record pattern in an if-case statement.
if (record case (int a, int b)) {
print(a + b);
}
The if-case is the pattern-matching conditional. The pattern matches the record and extracts the fields.
The record’s fields can be destructured with subpatterns.
switch (record) {
case (int a, int b) when a > b:
print('a is larger');
case (int a, int b):
print('b is larger or equal');
}
The when clause adds a guard. The pattern matches the shape and types, and the guard filters on the values .
c. Equality, subtyping, and the shape
Two records are equal if they have the same shape and their corresponding fields have equal values.
print((1, 2) == (1, 2)); // true
print((1, 2) == (1, 3)); // false
print((x: 1) == (x: 1)); // true
print((x: 1) == (y: 1)); // false, different names
The first comparison is true because the shapes match and the fields are equal. The second is false because the values differ. The third is true. The fourth is false because the named field names differ, which means the shapes differ .
The order of named fields does not affect equality.
print((x: 1, y: 2) == (y: 2, x: 1)); // true
The named fields are a set, not a sequence. The order is not significant .
A record type is a subtype of another record type if they have the same shape and each field type is a subtype of the corresponding field type.
(num, String) a = (1.2, "s");
(int, Object) b = (2, true);
var c = cond ? a : b; // c has type (num, Object)
The (int, String) is a subtype of (num, String) because int is a subtype of num and String is a subtype of String. The subtyping is pointwise and covariant .
Record types with different shapes are unrelated.
(int, int) point = (1, 2);
({int x, int y}) named = (x: 1, y: 2);
// point = named; // Error: different shapes
The (int, int) and the ({int x, int y}) have different shapes. They are not subtypes of each other .
A record type is a subtype of Record, which is a subtype of Object. The Record class is abstract and cannot be instantiated directly.
Record record = (1, 2);
print(record.runtimeType); // (int, int)
The Record type is the supertype of all record types. The runtime type is the specific record type .
Complete Example Session
// ============================================
// PART 1: BASIC RECORD
// ============================================
var point = (1, 2);
print(point.$1); // 1
print(point.$2); // 2
// ============================================
// PART 2: NAMED FIELDS
// ============================================
var user = (name: 'Alice', age: 30);
print(user.name); // 'Alice'
print(user.age); // 30
// ============================================
// PART 3: MIXED FIELDS
// ============================================
var record = (1, 'two', three: 3);
print(record.$1); // 1
print(record.$2); // 'two'
print(record.three); // 3
// ============================================
// PART 4: RECORD TYPE ANNOTATION
// ============================================
(int, String) pair = (42, 'answer');
print(pair.$1); // 42
print(pair.$2); // 'answer'
// ============================================
// PART 5: DESTRUCTURING
// ============================================
var (a, b) = (1, 2);
print(a); // 1
print(b); // 2
var (x: x, y: y) = (x: 1, y: 2);
print(x); // 1
print(y); // 2
// ============================================
// PART 6: SWITCH DESTRUCTURING
// ============================================
var record = (1, 'one', valid: true);
switch (record) {
case (int n, String s, valid: true):
print('$n is $s');
default:
print('no match');
}
// ============================================
// PART 7: RECORD EQUALITY
// ============================================
print((1, 2) == (1, 2)); // true
print((1, 2) == (1, 3)); // false
print((x: 1) == (x: 1)); // true
print((x: 1) == (y: 1)); // false
print((x: 1, y: 2) == (y: 2, x: 1)); // true
// ============================================
// PART 8: RECORD AS RETURN TYPE
// ============================================
(String, {DateTime modified}) getMetadata() {
return ('My Document', modified: DateTime.now());
}
var (title, modified: when) = getMetadata();
print('$title modified at $when');
// ============================================
// PART 9: NESTED RECORDS
// ============================================
var nested = (1, (2, 3));
print(nested.$1); // 1
print(nested.$2.$1); // 2
print(nested.$2.$2); // 3
// ============================================
// PART 10: RECORDS IN COLLECTIONS
// ============================================
var points = [(1, 2), (3, 4), (5, 6)];
for (var (x, y) in points) {
print('($x, $y)');
}
The ten parts covered the basic record, named fields, mixed fields, the type annotation, destructuring, switch destructuring, record equality, a record return type, nested records, and records in collections.
Quick Reference
Record Expression
| Form | Example |
|---|---|
| Positional | (1, 2) |
| Named | (x: 1, y: 2) |
| Mixed | (1, 2, z: 3) |
| Single with comma | (1,) |
| Empty | () |
Record Type
| Form | Example |
|---|---|
| Positional | (int, int) |
| Named | ({int x, int y}) |
| Mixed | (int, int, {int z}) |
| Single | (int,) |
| Empty | () |
Field Access
| Field | Getter |
|---|---|
| First positional | .$1 |
| Second positional | .$2 |
| Named | .name |
Destructuring
| Form | Example |
|---|---|
| Positional | var (a, b) = record; |
| Named | var (x: a) = record; |
| Shorthand | var (x, y) = record; |
| Nested | var (a, (b, c)) = record; |
Equality
| Condition | Equal |
|---|---|
| Same shape, same values | Yes |
| Same shape, different values | No |
| Different named field names | No |
| Different named field order | Yes |
Best Practices
✅ Do This:
// Use records for multiple return values
(String, int) parseName(String input) { } // ✅
// Use named fields for clarity
({String name, int age}) user = (name: 'Alice', age: 30); // ✅
// Destructure with patterns
var (name, age) = user; // ✅
// Use records in switch for shape matching
switch (record) {
case (int a, int b): // ...
} // ✅
// Use records as map keys (structural equality)
var map = {(1, 2): 'value'}; // ✅
// Use nested records for structured data
var data = (user: (name: 'Alice', age: 30), active: true); // ✅
❌ Don’t Do This:
// Don't use a single positional field without a trailing comma
var single = (1); // parenthesized expression // ❌
// Don't use an underscore-prefixed field name
var bad = (_x: 1); // error // ❌
// Don't use Object member names as field names
var bad = (hashCode: 1); // error // ❌
// Don't expect records to have identity
var a = (1, 2);
var b = (1, 2);
print(identical(a, b)); // may be false // ⚠️
// Don't use records for aggregates with behavior
// Use a class when there are methods or invariants // ⚠️
// Don't forget the shape is part of the type
(int, int) point = (1, 2);
({int x, int y}) named = (x: 1, y: 2);
// point = named; // error // ❌
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Single field error | No trailing comma | Add (1,) |
| Field name error | Underscore or Object member | Rename the field |
| Equality false | Different shapes | Match the shape |
| Type mismatch | Different field names | Match the names |
| Getter not found | Wrong position | Check $1, $2 |
| Immutable error | Tried to assign | Records are immutable |
Real-World Examples
1. Multiple Return Values
(String, int) splitName(String full) {
var parts = full.split(' ');
return (parts[0], parts.length > 1 ? int.parse(parts[1]) : 0);
}
2. Quotient and Remainder
(int, int) divmod(int a, int b) => (a ~/ b, a % b);
3. Point
(double, double) point = (3.0, 4.0);
4. User Info
({String name, int age}) user = (name: 'Alice', age: 30);
5. Document Metadata
(String, {DateTime modified}) get metadata =>
('Title', modified: DateTime.now());
6. Destructuring in a Loop
for (var (key, value) in map.entries) {
print('$key: $value');
}
7. Switch on Record
switch ((status, code)) {
case ('ok', 200): print('Success');
case ('error', 404): print('Not found');
default: print('Unknown');
}
8. Nested Record
var response = (data: (id: 1, name: 'Alice'), error: null);
9. Record as Map Key
var cache = <(int, int), String>{};
cache[(1, 2)] = 'value';
10. Record in a List
var pairs = [(1, 'a'), (2, 'b'), (3, 'c')];
for (var (n, s) in pairs) {
print('$n = $s');
}
Visual
Record Shape
┌─────────────────────────────────────────────────────────────┐
│ var record = (1, 'two', three: 3); │
│ │
│ Shape: 2 positional fields, 1 named field "three" │
│ │
│ Access: │
│ record.$1 → 1 │
│ record.$2 → 'two' │
│ record.three → 3 │
│ │
│ The shape determines the type. │
│ Two records with the same shape and types are the same. │
│ │
└─────────────────────────────────────────────────────────────┘
Record Equality
┌─────────────────────────────────────────────────────────────┐
│ (1, 2) == (1, 2) → true │
│ (1, 2) == (1, 3) → false │
│ (x: 1) == (x: 1) → true │
│ (x: 1) == (y: 1) → false (different names) │
│ (x: 1, y: 2) == (y: 2, x: 1) → true (order irrelevant) │
│ │
│ Equality is structural. │
│ The shape and the values determine equality. │
│ │
└─────────────────────────────────────────────────────────────┘
Destructuring
┌─────────────────────────────────────────────────────────────┐
│ var record = (1, 'two', three: 3); │
│ │
│ var (a, b, three: c) = record; │
│ │
│ a = 1 │
│ b = 'two' │
│ c = 3 │
│ │
│ The pattern matches the shape and extracts the fields. │
│ │
└─────────────────────────────────────────────────────────────┘
Record vs Class
┌─────────────────────────────────────────────────────────────┐
│ RECORD │
│ │
│ Anonymous │
│ Structural equality │
│ Immutable by default │
│ No methods │
│ Lightweight │
│ │
├─────────────────────────────────────────────────────────────┤
│ │
│ CLASS │
│ │
│ Named │
│ Identity equality by default │
│ Mutable by default │
│ Methods and behavior │
│ More ceremony │
│ │
│ Records are for data. Classes are for behavior. │
│ │
└─────────────────────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Record | Anonymous, immutable aggregate |
| Positional | (1, 2) |
| Named | (x: 1, y: 2) |
| Mixed | (1, 2, z: 3) |
| Type | (int, int), ({int x, int y}) |
| Access positional | .$1, .$2 |
| Access named | .name |
| Destructuring | var (a, b) = record; |
| Equality | Structural |
| Subtyping | Same shape, covariant fields |
| Single field | (1,) with trailing comma |
| Empty | () |
Key takeaways:
- A record is an anonymous, immutable aggregate type. It bundles multiple values of different types into a single object. The shape—the positional fields and the named fields—determines the type.
- Records are structurally typed. Two records with the same shape and the same field types are the same type, even if they are declared in unrelated libraries. The names of positional fields are documentation only; the names of named fields are part of the type .
- Fields are accessed by position or by name. Positional fields use
$1,$2, and so on. Named fields use their names. The fields are immutable; there are no setters. - Records are destructured with patterns. A variable declaration, a switch case, or an
if-casecan destructure a record into local variables. The pattern matches the shape and the types. - Records have structural equality. Two records are equal if they have the same shape and their corresponding fields are equal. The order of named fields does not affect equality. The
hashCodeis consistent with equality, making records usable as map keys . - Record types are covariant in their fields. A record type is a subtype of another record type if they have the same shape and each field type is a subtype of the corresponding field type. Record types with different shapes are unrelated .
- A single positional field requires a trailing comma.
(1,)is a record with one positional field.(1)is a parenthesized expression. The trailing comma disambiguates .
Remember: Records are the tool for aggregating data without declaring a class. They are anonymous, immutable, and structurally typed. The shape is the type, and the fields are accessed by position or by name. Use records for multiple return values, for grouping related values, and for the data that flows through patterns. Use classes when the aggregate has behavior, invariants, or a lifecycle. The two are complementary: records for the data, classes for the behavior. And remember the equality rules: two records with the same shape and equal values are equal, which is the property that makes records value types.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!