Replacing tuples with records

djocubeit

Dom Jocubeit

Posted on July 3, 2023

Replacing tuples with records

A refactoring use-case for dart’s new language feature

With the release of Dart 3.0 came a new feature — records. You can learn about records in the Dart language documentation.

For those unaware, to paraphrase: records are an anonymous, immutable, aggregate type. Records let you bundle multiple objects into a single object, but unlike other collection types, records are fixed-sized, heterogeneous, and typed. Records are real values; you can store them in variables, nest them, pass them to and from functions, and store them in data structures such as lists, maps, and sets.

A practical use-case

I had an occasion to use the new records feature in updating a widget. I thought I’d share where the records feature works and the benefits gained.

The widget is not important, but for reference, it’s a table. The table has an undo feature for changes to rows.

In the original version, a tuple (package:tuple) with five dimensions was used to record the nature and details of a change to a row permitting a user to reverse the change if necessary. The tuples are stored on a stack, so undo is simply a matter of calling stack.pop(). The old code looked something like this:

import 'package:stack/stack.dart';
import 'package:tuple/tuple.dart';

enum UndoAction {
  created,
  updated,
  deleted,
  reordered,
}

class UndoState {
  // undo action, old state, new state, old index, new index
  final state = Stack<Tuple5<UndoAction, RowState?, RowState?, int?, int?>>();
}
Enter fullscreen mode Exit fullscreen mode

An example of creating a tuple and adding it to the stack looks something like this:

import 'package:tuple/tuple.dart';

  ...
  onReorder: (oldIndex, newIndex) {
    setState(() {
      final tuple = Tuple(
        UndoAction.reordered, // undo action
        _tableSource.rows[newIndex], // old state
        null, // new state (n/a)
        oldIndex, // old index
        newIndex, // new index
      );

      _tableSource.undo.state.push(tuple);
      ...
    });
  }
  ...
Enter fullscreen mode Exit fullscreen mode

Notice the comments on each element to imbue its semantics.

Even worse is accessing the elements of the tuple:

void _undoRow() {
  if (_tableSource.undo.state.isNotEmpty) {
    setState(() {
      // Retrieve the last action and delete it from the stack
      final lastAction = _tableSource.undo.state.pop();

      // Undo the last action
      switch (lastAction.item1) {
        ...
        case UndoAction.reordered:
          final rowState = lastAction.item2!;
          final oldIndex = lastAction.item4!;
          final newIndex = lastAction.item5!;
          _tableSource.rows.removeAt(newIndex);
          _tableSource.rows.insert(oldIndex, rowState);
          break;
        ...
      }
    });
    ...
  }
}
Enter fullscreen mode Exit fullscreen mode

The elements have to be accessed using the itemN syntax. It's very unclear what the element represents in the structure, so appropriately named variables are required here.

Replacing with records

A simple swap in replacement of the tuple with a record looks like the following:

import 'package:stack/stack.dart';

enum UndoAction {
  created,
  updated,
  deleted,
  reordered,
}

final state = Stack<(UndoAction, RowState?, RowState?, int?, int?)>();
Enter fullscreen mode Exit fullscreen mode

We immediately remove the need for the tuple package, so that can go.

And using it looks like this:

  ...
  onReorder: (oldIndex, newIndex) {
    setState(() {
      final undoRecord = (
        UndoAction.reordered, // undo action
        _tableSource.rows[newIndex], // old state
        null, // new state (n/a)
        oldIndex, // old index
        newIndex, // new index
      );

      _tableSource.undo.state.push(undoRecord);
      ...
    });
  }
  ...
Enter fullscreen mode Exit fullscreen mode

And accessing the record fields is as follows:

void _undoRow() {
  if (_tableSource.undo.state.isNotEmpty) {
    setState(() {
      // Retrieve the last action and delete it from the stack
      final lastAction = _tableSource.undo.state.pop();

      // Undo the last action
      switch (lastAction.$1) {
        ...
        case UndoAction.reordered:
          final rowState = lastAction.$2!;
          final oldIndex = lastAction.$4!;
          final newIndex = lastAction.$5!;
          _tableSource.rows.removeAt(newIndex);
          _tableSource.rows.insert(oldIndex, rowState);
          break;
        ...
      }
    });
    ...
  }
}
Enter fullscreen mode Exit fullscreen mode

Now we access the fields of the record using the $n syntax.

Have we swapped out one evil for another? Not quite, we did remove a dependency remember?

Where records shine

Records have a compelling feature — named fields. We can name the fields, and then access them using their field names.

Here is the updated code using named fields:

import 'package:stack/stack.dart';

enum UndoAction {
  created,
  updated,
  deleted,
  reordered,
}

class UndoState {
  final state = Stack<
      ({
        UndoAction undoAction,
        RowState? oldState,
        RowState? newState,
        int? oldIndex,
        int? newIndex,
      })>();
}
Enter fullscreen mode Exit fullscreen mode

An example of creating a record and adding it to the stack now looks something like this:

  ...
  onReorder: (oldIndex, newIndex) {
    setState(() {
      final undoRecord = (
        undoAction: UndoAction.reordered,
        oldState: _tableSource.rows[newIndex],
        newState: null,
        oldIndex: oldIndex,
        newIndex: newIndex,
      );

      _tableSource.undo.state.push(undoRecord);
      ...
    });
  }
  ...
Enter fullscreen mode Exit fullscreen mode

And accessing the fields is as follows:

void _undoRow() {
  if (_tableSource.undo.state.isNotEmpty) {
    setState(() {
      // Retrieve the last action and delete it from the stack
      final lastAction = _tableSource.undo.state.pop();

      // Undo the last action
      switch (lastAction.undoAction) {
        ...
        case UndoAction.reordered:
          _tableSource.rows.removeAt(lastAction.newIndex!);
          _tableSource.rows.insert(lastAction.oldIndex!, lastAction.oldState!);
          break;
        ...
      }
    });
    ...
  }
}
Enter fullscreen mode Exit fullscreen mode

What an improvement! Comments and variable assignments are gone because it’s now understandable. A dependency is gone, because records are now a first-class language feature. A big thank you to the Dart team.

After writing this article I decided to double-check my link to the tuple package in pub.dev. The following notice is present:

By and large, Records serve the same use cases that package:tuple had been used for. New users coming to this package should likely look at using Dart Records instead. Existing uses of package:tuple will continue to work, however, we don’t intend to enhance the functionality of this package; we will continue to maintain this package from the POV of bug fixes.

Now, I understand some people will argue a class is a better structure for this, and they might be right. I feel however that a record works well in this instance, and happy to be enlightened with justified reasoning.

What do you think, are records a valid replacement for the tuple package here?

Other information

This post originally appears on my personal web site at https://dom.jocubeit.com/replacing-tuples-with-records.

I used my one free credit at STOCKIMG.AI to generate a horizontal poster using disco diffusion for the article poster image. My casual, yet very inadequate prompt was: “dart programming language code flying in background, records in the foreground”. The image above is the generated result. Nothing like what I was expecting, but kinda cool; so I thought I’d use it anyway.

💖 💪 🙅 🚩
djocubeit
Dom Jocubeit

Posted on July 3, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related