Replacing tuples with records

Dom JocubeitDom Jocubeit
5 min read

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.
2
Subscribe to my newsletter

Read articles from Dom Jocubeit directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Dom Jocubeit
Dom Jocubeit

Experienced full stack software architect and electronics engineer skilled in SaaS, PaaS, blockchain, AI & ML, API dev, Flutter, Dart, GraphQL and Postgres.