Dart Advanced Features

Records & Tuples (Dart 3)

45 min Lesson 6 of 16

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 = ();
}
Warning: Positional record fields are 1-indexed, not 0-indexed. The first field is $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)
}
Tip: Prefer named records over positional records when the meaning of each field is not obvious from context. (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
}
Note: Record equality is deep, meaning nested records are compared recursively. ((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);
}
Tip: If you find yourself creating a 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.

Note: Records are immutable. Once created, you cannot change a record’s fields. If you need a modified version, create a new record. This immutability makes records inherently thread-safe and ideal for use with isolates and state management.