Records & Tuples (Dart 3)
What Are Records?
Records, introduced in Dart 3.0, are anonymous, immutable, aggregate types. They let you bundle multiple values together into a single object without defining a class. Think of them as lightweight, type-safe tuples that Dart has always been missing.
Before records, if you wanted to return multiple values from a function, you had three awkward options: return a List (losing type safety), return a Map (losing structure), or create a dedicated class (too much boilerplate for simple cases). Records solve all three problems elegantly.
The Problem Records Solve
// Before Dart 3: Returning multiple values was clunky
// Option 1: List (no type safety for individual elements)
List<dynamic> getMinMax(List<int> numbers) {
return [numbers.reduce(min), numbers.reduce(max)];
}
// Usage: final result = getMinMax([3, 1, 4]); // result[0]? result[1]?
// Option 2: Map (no structure, stringly-typed)
Map<String, int> getMinMaxMap(List<int> numbers) {
return {'min': numbers.reduce(min), 'max': numbers.reduce(max)};
}
// Option 3: Custom class (too much ceremony)
class MinMaxResult {
final int min;
final int max;
MinMaxResult(this.min, this.max);
}
// Dart 3: Records -- clean, type-safe, zero boilerplate
(int, int) getMinMaxRecord(List<int> numbers) {
return (numbers.reduce(min), numbers.reduce(max));
}
// Usage: final (min, max) = getMinMaxRecord([3, 1, 4]);
Positional Records
The simplest form of record uses positional fields. You define the types in parentheses, and access fields using $1, $2, etc. (1-indexed, not 0-indexed).
Creating Positional Records
void main() {
// A record with two positional fields
(String, int) person = ('Edrees', 28);
// Access fields with $1, $2, $3, etc.
print(person.$1); // Edrees
print(person.$2); // 28
// Records can hold different types
(String, double, bool) product = ('Laptop', 999.99, true);
print('${product.$1}: \$${product.$2} (in stock: ${product.$3})');
// Laptop: $999.99 (in stock: true)
// Single-element records need a trailing comma
(int,) singleRecord = (42,);
print(singleRecord.$1); // 42
// Empty records are also valid (but rarely useful)
() emptyRecord = ();
}
$1, the second is $2, and so on. This is different from lists and arrays in most languages. Also note the trailing comma for single-element records -- (42) is just a parenthesized expression, while (42,) is a record.Named Records
Named records use curly braces to give fields descriptive names, making code more readable and self-documenting. Named fields are accessed by their name, not by position.
Creating Named Records
void main() {
// A record with named fields
({String name, int age}) person = (name: 'Edrees', age: 28);
// Access by name
print(person.name); // Edrees
print(person.age); // 28
// Order of named fields does not matter when creating
({String city, String country}) location =
(country: 'Saudi Arabia', city: 'Riyadh');
print('${location.city}, ${location.country}');
// Riyadh, Saudi Arabia
// Mix of positional AND named fields
(String, {int age, String role}) employee =
('Ahmed', age: 30, role: 'Developer');
print(employee.$1); // Ahmed (positional)
print(employee.age); // 30 (named)
print(employee.role); // Developer (named)
}
(double, double) could mean anything -- latitude/longitude, width/height, x/y. But ({double latitude, double longitude}) is self-documenting. Use positional records for simple, obvious pairs like (int, int) for min/max or (String, bool) for name/isActive.Record Types
Every record has a type determined by the types, names, and positions of its fields. Two records have the same type if and only if they have the same field types in the same positions with the same names.
Record Type Identity
void main() {
// These have the SAME type: (String, int)
(String, int) a = ('Hello', 42);
(String, int) b = ('World', 99);
// These have DIFFERENT types
(String, int) c = ('Hello', 42); // positional
(int, String) d = (42, 'Hello'); // order matters!
({String name, int value}) e = (name: 'Hello', value: 42); // named
// Named field order does NOT matter for type identity
({int age, String name}) f = (name: 'Test', age: 25);
({String name, int age}) g = (name: 'Test', age: 25);
// f and g have the SAME type -- named field order is irrelevant
// You can use type aliases for complex record types
typedef Coordinate = ({double x, double y});
typedef UserInfo = (String name, int age, {String email});
Coordinate point = (x: 10.5, y: 20.3);
UserInfo user = ('Edrees', 28, email: 'edrees@example.com');
}
Record Equality
Records have structural equality built in. Two records are equal if they have the same type and all corresponding fields are equal. You do not need to override == or hashCode -- it works automatically.
Structural Equality
void main() {
// Positional records: equal if same type and same values
final a = ('Edrees', 28);
final b = ('Edrees', 28);
final c = ('Ahmed', 28);
print(a == b); // true (same values)
print(a == c); // false (different name)
// Named records: equal if same type and same field values
final p1 = (x: 10.0, y: 20.0);
final p2 = (x: 10.0, y: 20.0);
final p3 = (x: 10.0, y: 30.0);
print(p1 == p2); // true
print(p1 == p3); // false
// hashCode is also consistent
print(a.hashCode == b.hashCode); // true
// This means records work correctly in Sets and as Map keys
final pointSet = <({double x, double y})>{};
pointSet.add((x: 1.0, y: 2.0));
pointSet.add((x: 1.0, y: 2.0)); // Duplicate -- not added
pointSet.add((x: 3.0, y: 4.0));
print(pointSet.length); // 2
}
((1, 2), (3, 4)) == ((1, 2), (3, 4)) is true. This is different from classes, where the default == is reference equality.Destructuring Records
Destructuring (also called unpacking) lets you extract record fields into individual variables in a single statement. This is one of the most powerful features of records.
Destructuring Positional Records
void main() {
// Destructure a positional record
final (name, age) = ('Edrees', 28);
print('$name is $age years old'); // Edrees is 28 years old
// Destructure with type annotations
final (String city, int population) = ('Riyadh', 7500000);
print('$city has $population people');
// Destructure in a for loop
final points = [(1, 2), (3, 4), (5, 6)];
for (final (x, y) in points) {
print('Point: ($x, $y)');
}
// Use _ to ignore fields you do not need
final (_, secondValue) = ('ignored', 42);
print(secondValue); // 42
}
Destructuring Named Records
void main() {
// Destructure named fields
final (:name, :age) = (name: 'Edrees', age: 28);
print('$name is $age'); // Edrees is 28
// Rename during destructuring
final (name: userName, age: userAge) = (name: 'Ahmed', age: 25);
print('$userName is $userAge'); // Ahmed is 25
// Mixed positional and named
final (title, :year, :rating) =
('Inception', year: 2010, rating: 8.8);
print('$title ($year) -- $rating/10');
// Inception (2010) -- 8.8/10
}
Records as Return Types
The most common use case for records is returning multiple values from a function. This replaces the need for wrapper classes, output parameters, or returning untyped collections.
Functions Returning Records
import 'dart:math';
// Return min and max as a positional record
(int min, int max) findMinMax(List<int> numbers) {
int minVal = numbers[0];
int maxVal = numbers[0];
for (final n in numbers) {
if (n < minVal) minVal = n;
if (n > maxVal) maxVal = n;
}
return (minVal, maxVal);
}
// Return user lookup result with named fields
({bool found, String? name, int? age}) findUser(int id) {
// Simulate database lookup
if (id == 1) {
return (found: true, name: 'Edrees', age: 28);
}
return (found: false, name: null, age: null);
}
// Return parsed coordinate
({double latitude, double longitude}) parseCoordinate(String input) {
final parts = input.split(',');
return (
latitude: double.parse(parts[0].trim()),
longitude: double.parse(parts[1].trim()),
);
}
void main() {
// Destructure the return value directly
final (min, max) = findMinMax([5, 2, 8, 1, 9, 3]);
print('Min: $min, Max: $max'); // Min: 1, Max: 9
// Use named destructuring
final (:found, :name, :age) = findUser(1);
if (found) {
print('Found: $name, age $age'); // Found: Edrees, age 28
}
// Destructure coordinate
final (:latitude, :longitude) = parseCoordinate('24.7136, 46.6753');
print('Lat: $latitude, Lng: $longitude');
// Lat: 24.7136, Lng: 46.6753
}
Records vs Classes
Records and classes serve different purposes. Here is a clear guide for when to use each:
Use records when:
- You need to return multiple values from a function
- The data is a simple, short-lived grouping
- You want structural equality without writing
==/hashCode - The data has no behavior (methods)
Use classes when:
- The data has behavior (methods) associated with it
- You need inheritance or interface implementation
- The data represents a long-lived domain entity
- You need encapsulation (private fields)
- The type needs a meaningful name in your domain model
Records vs Classes Decision
// GOOD use of record: simple multiple return
(bool success, String message) validate(String email) {
if (email.contains('@')) {
return (true, 'Valid email');
}
return (false, 'Invalid email format');
}
// GOOD use of class: has behavior and identity
class EmailValidator {
final List<String> _blockedDomains;
final int _maxLength;
EmailValidator({
List<String> blockedDomains = const [],
int maxLength = 254,
}) : _blockedDomains = blockedDomains,
_maxLength = maxLength;
bool isValid(String email) {
if (!email.contains('@')) return false;
if (email.length > _maxLength) return false;
final domain = email.split('@').last;
return !_blockedDomains.contains(domain);
}
}
// GOOD use of record: coordinate pair
({double x, double y}) translate(
({double x, double y}) point,
double dx,
double dy,
) {
return (x: point.x + dx, y: point.y + dy);
}
// GOOD use of class: coordinate with behavior
class Point {
final double x;
final double y;
const Point(this.x, this.y);
double distanceTo(Point other) {
return sqrt(pow(x - other.x, 2) + pow(y - other.y, 2));
}
Point operator +(Point other) => Point(x + other.x, y + other.y);
}
typedef for a record type and using it in more than 3-4 places, it is probably time to promote it to a proper class. Records are best for local, ephemeral data groupings, not for core domain types that appear throughout your codebase.Practical Example: API Response Handling
Records are perfect for handling API responses where you need to return both data and metadata.
API Response with Records
import 'dart:convert';
// Simulate an API response type
typedef ApiResponse<T> = ({T data, int statusCode, String? error});
// Parse a paginated API response
({List<Map<String, dynamic>> items, int total, int page, int perPage})
parsePaginatedResponse(String jsonString) {
final json = jsonDecode(jsonString) as Map<String, dynamic>;
return (
items: (json['data'] as List).cast<Map<String, dynamic>>(),
total: json['total'] as int,
page: json['page'] as int,
perPage: json['per_page'] as int,
);
}
// Simulate fetching and parsing
Future<({bool success, List<String> names, String? error})>
fetchUserNames() async {
try {
// Simulate API call
await Future.delayed(Duration(milliseconds: 100));
final mockResponse = '{"users": ["Edrees", "Ahmed", "Sara"]}';
final json = jsonDecode(mockResponse) as Map<String, dynamic>;
final names = (json['users'] as List).cast<String>();
return (success: true, names: names, error: null);
} catch (e) {
return (success: false, names: <String>[], error: e.toString());
}
}
Future<void> main() async {
final (:success, :names, :error) = await fetchUserNames();
if (success) {
print('Users: ${names.join(", ")}');
// Users: Edrees, Ahmed, Sara
} else {
print('Error: $error');
}
}
Practical Example: Coordinate System
Records work beautifully for coordinate transformations and geometric calculations.
Coordinate Operations with Records
import 'dart:math';
typedef Point2D = ({double x, double y});
typedef Point3D = ({double x, double y, double z});
// Convert polar coordinates to Cartesian
Point2D polarToCartesian(double radius, double angleRadians) {
return (
x: radius * cos(angleRadians),
y: radius * sin(angleRadians),
);
}
// Calculate distance between two points
double distance(Point2D a, Point2D b) {
return sqrt(pow(a.x - b.x, 2) + pow(a.y - b.y, 2));
}
// Midpoint between two points
Point2D midpoint(Point2D a, Point2D b) {
return (x: (a.x + b.x) / 2, y: (a.y + b.y) / 2);
}
// Bounding box of a list of points
({Point2D topLeft, Point2D bottomRight}) boundingBox(List<Point2D> points) {
double minX = points.first.x, maxX = points.first.x;
double minY = points.first.y, maxY = points.first.y;
for (final p in points) {
if (p.x < minX) minX = p.x;
if (p.x > maxX) maxX = p.x;
if (p.y < minY) minY = p.y;
if (p.y > maxY) maxY = p.y;
}
return (
topLeft: (x: minX, y: minY),
bottomRight: (x: maxX, y: maxY),
);
}
void main() {
final origin = (x: 0.0, y: 0.0);
final point = polarToCartesian(5.0, pi / 4);
print('Polar (5, 45°) → Cartesian (${point.x.toStringAsFixed(2)}, ${point.y.toStringAsFixed(2)})');
// Polar (5, 45°) → Cartesian (3.54, 3.54)
final d = distance(origin, point);
print('Distance from origin: ${d.toStringAsFixed(2)}'); // 5.00
final mid = midpoint(origin, point);
print('Midpoint: (${mid.x.toStringAsFixed(2)}, ${mid.y.toStringAsFixed(2)})');
// Midpoint: (1.77, 1.77)
final points = [(x: 1.0, y: 3.0), (x: 5.0, y: 1.0), (x: 3.0, y: 7.0)];
final (:topLeft, :bottomRight) = boundingBox(points);
print('Bounding box: ($topLeft) to ($bottomRight)');
}
Summary
Records in Dart 3 are a powerful addition that eliminates the need for boilerplate wrapper classes when bundling multiple values. They support positional and named fields, provide structural equality out of the box, and integrate seamlessly with destructuring. Use records for simple data groupings and function return types, and classes for types that need behavior, encapsulation, or identity. Combined with pattern matching (covered in the next lesson), records become an essential tool for writing clean, expressive Dart code.