← 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's Hidden Gem: Building Powerful Linux Desktop Apps with Ease

Flutter's Hidden Gem: Building Powerful Linux Desktop Apps with Ease

Flutter is gaining traction as a robust framework for Linux desktop development, offering a smoother experience compared to traditional toolkits like GTK and Qt. This post will explore why Flutter excels for Linux apps, highlight its advantages, and provide practical insights for developers looking to leverage Flutter for their desktop projects.

Cover image for Simplifying Flutter Desktop Deployment: Signing and Distribution for Windows

Simplifying Flutter Desktop Deployment: Signing and Distribution for Windows

Deploying Flutter desktop apps on Windows can be challenging, especially regarding code signing and preventing Windows from blocking installations. This post will guide developers through the process of signing their Flutter desktop applications, exploring alternatives to the Microsoft Store like creating executable installers, and covering the necessary steps to ensure a smooth user experience.

Cover image for Mastering Flutter Tooling: Streamlining SDK Management and Installation on Windows

Mastering Flutter Tooling: Streamlining SDK Management and Installation on Windows

Developers frequently voice frustrations about Flutter SDK version management and installation processes, particularly on Windows. This post will explore best practices for managing multiple Flutter versions using tools like FVM, discuss integrating Flutter with package managers like Winget for a smoother Windows setup, and offer strategies to ensure a consistent development environment across projects and teams.