Replacing tuples with records

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?>>();
}

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);
      ...
    });
  }
  ...

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;
        ...
      }
    });
    ...
  }
}

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?)>();

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);
      ...
    });
  }
  ...

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;
        ...
      }
    });
    ...
  }
}

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,
      })>();
}

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);
      ...
    });
  }
  ...

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;
        ...
      }
    });
    ...
  }
}

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 enlighted with justified reasoning.

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


Other info

About the header image
I used my one free credit at STOCKIMG.AI to generate a horizontal poster using disco diffusion. 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.