← Back to posts Cover image for Mastering Dart Records: Beyond Basic Usage for Cleaner Code and API Responses

Mastering Dart Records: Beyond Basic Usage for Cleaner Code and API Responses

· 5 min read
Weekly Digest

The Flutter news you actually need

No spam, ever. Unsubscribe in one click.

Chris
By Chris

Mastering Dart Records: Beyond Basic Usage for Cleaner Code and API Responses

Dart records, introduced in Dart 3, have quickly become one of my favorite language features. At first glance, they seem like a simple way to return multiple values from a function without defining a custom class. But once you dive deeper, you’ll discover they’re incredibly powerful tools for writing cleaner, more type-safe, and more maintainable code—especially when dealing with API responses or complex internal data flows.

The Basic Problem: Returning Multiple Values

Before records, if you needed to return multiple values from a function, you had a few clunky options:

// Option 1: Using a List (type unsafe, unclear)
List<dynamic> getUserData() => [42, 'Alice'];

// Option 2: Using a Map (still type unsafe at runtime)
Map<String, dynamic> getUserData() => {'id': 42, 'name': 'Alice'};

// Option 3: Creating a custom class (verbose for simple cases)
class UserData {
  final int id;
  final String name;
  UserData(this.id, this.name);
}

Each approach has drawbacks: Lists and Maps lack type safety for individual fields, while creating a custom class feels like overkill for temporary data. Enter Dart records.

Records to the Rescue: Positional vs. Named Fields

Here’s the basic syntax that solves our problem:

// Positional record - order matters
(int, String) getUserData() => (42, 'Alice');

void main() {
  var data = getUserData();
  print(data.$1); // 42
  print(data.$2); // 'Alice'
}

This is already better—we have type safety! But accessing fields as $1 and $2 isn’t very readable. This is where named record fields shine:

// Named record - self-documenting
({int id, String name}) getUserData() => (id: 42, name: 'Alice');

void main() {
  var data = getUserData();
  print(data.id);   // Much clearer!
  print(data.name);
}

The key insight here is that you can name positional fields in the type annotation for documentation, but you can also create true named records using the ({}) syntax where names are part of the type.

Where This Gets Powerful: API Responses and Complex Returns

Let’s look at a practical example. Imagine you’re fetching data from an API and need to return both the result and any potential error:

({List<Product>? products, String? error}) fetchProducts() {
  try {
    // Simulate API call
    return (products: [Product('Widget'), Product('Gadget')], error: null);
  } catch (e) {
    return (products: null, error: 'Failed to fetch products: $e');
  }
}

void main() {
  var result = fetchProducts();
  
  if (result.error != null) {
    showError(result.error!);
    return;
  }
  
  // Type promotion works here!
  displayProducts(result.products!);
}

This pattern is incredibly clean. Compare it to the old approach where you might return a tuple and check $2 for an error—completely unreadable.

Records for Internal Data Transformation

Another great use case is when you need to process data and return multiple transformed values:

({double total, double average, int count}) analyzeSales(List<double> sales) {
  if (sales.isEmpty) return (total: 0, average: 0, count: 0);
  
  final total = sales.fold(0.0, (sum, sale) => sum + sale);
  final average = total / sales.length;
  
  return (total: total, average: average, count: sales.length);
}

// Usage is crystal clear
void displayReport(List<double> sales) {
  final analysis = analyzeSales(sales);
  print('Total: \$${analysis.total}');
  print('Average: \$${analysis.average}');
  print('Count: ${analysis.count}');
}

Common Mistakes and Best Practices

  1. Don’t confuse positional field names with true named records:

    // These are DIFFERENT types!
    (int x, int y) point1 = (1, 2);          // Positional
    ({int x, int y}) point2 = (x: 1, y: 2);  // Named
    
    // This won't compile - different types
    // point1 = point2;
  2. Use typedef for complex record types:

    typedef ApiResponse = ({List<User>? data, String? error, int statusCode});
    
    ApiResponse fetchUsers() {
      return (data: [User('Alice')], error: null, statusCode: 200);
    }
  3. Records are immutable - once created, you can’t modify their fields. This is a feature, not a bug!

  4. Destructure records for clean code:

    final (:total, :average, :count) = analyzeSales(salesData);
    print('Average of $count items: $average');

When to Use Records vs. Classes

Use records when:

  • The data structure is simple and temporary
  • You need to return multiple values from a function
  • The data doesn’t need methods attached to it
  • You want to avoid the ceremony of creating a class

Use classes when:

  • The data needs associated methods
  • You need inheritance or polymorphism
  • The data structure is complex and used throughout your codebase
  • You need mutability or private fields

Wrapping Up

Dart records are more than just syntactic sugar. They provide a lightweight, type-safe way to bundle data together without the overhead of classes. By leveraging named fields, you can create self-documenting code that’s easier to read and maintain. The next time you find yourself reaching for a List or Map to return multiple values, consider whether a record might be the cleaner, safer choice instead.

Start incorporating records into your data transformation functions, API response handlers, and anywhere you need temporary data structures. Your future self (and your teammates) will thank you for the improved readability and type safety.

This blog is produced with the assistance of AI by a human editor. Learn more

Related Posts

Cover image for Flutter for High-Performance Desktop: Is it Ready for CAD, Image Processing, and Complex GUIs?

Flutter for High-Performance Desktop: Is it Ready for CAD, Image Processing, and Complex GUIs?

Developers are curious about Flutter's capabilities beyond typical business apps, especially for demanding desktop applications like CAD/CAM or image/video processing. This post will explore Flutter's suitability for high-performance, viewport-based desktop GUIs, discussing Dart's memory model, the 60fps update loop, and real-world examples to gauge its readiness for 'serious' complex software.

Cover image for Debugging Flutter Web Navigation: Solving the Deep Link Refresh Bug

Debugging Flutter Web Navigation: Solving the Deep Link Refresh Bug

Flutter web applications often suffer from a frustrating 'deep link refresh bug' where refreshing the browser on a nested route (e.g., /home/details) bounces the user back to the root or an incorrect path. This post will diagnose the common causes of this issue, explain how Flutter's router handles web URLs, and provide practical solutions and best practices for building robust, refresh-proof navigation in your Flutter web apps.

Cover image for Mastering Internationalization in Flutter: Centralized Strings for Scalable Apps

Mastering Internationalization in Flutter: Centralized Strings for Scalable Apps

As Flutter applications grow, managing strings for multiple languages or just keeping text consistent becomes a challenge. This post will guide developers through effective strategies for centralizing strings, implementing robust internationalization (i18n) and localization (l10n), and leveraging tools to streamline the process for small to large-scale projects.