The Dart Side Blog by OnePub - When not to use Dart Records

With the release of Dart 3.0, there has been a lot of excitement and articles around the new Records feature.

Like any new hammer, everyone now sees everything as a nail or in this case, a reason to use the new Records type.

To provide a little balance to the conversation, we are going to have a look at when you shouldn’t use the Record type.

To do so we are going to look at some of the published examples drawing on several sources.

I’ve made the decision not to attribute the sources as I don’t want this to be an exercise in criticizing members of the Dart community. I would rather view these contributions as useful in that they generate a public discourse which we can all learn from. The fact that I’m writing this blog demonstrates that the source material is useful. I also acknowledge that some of these examples were just that, examples rather than an intended solution. If you recognize your work in the following discourse and want to be attributed or the right of reply please feel free to drop me a line and I will update this blog. bsutton at onepub.dev.

Let's start with a quick summary of the Records syntax from the dart tour:

var record = ('first', a: 2, b: true, 'last');

I like to think of a record as an unnamed class that supports a mix of positional and named fields.

Here is an example of how we might use a Record that returns the latitude and longitude for a given address.


(int, int) latlong(Address address) => (address.lat, address.long);

So what might look to be a good example of using a record, turns out to be a bad use of a record.

The problem is that the return values are unnamed and therefore we access them by position.

  final geo = latlong(address);
  print('long: ${geo.$2}');
  print('lat: ${geo.$1}');

Can you see the mistake?

I’ve reversed the arguments. I’ve treated $2 as if it has the longitude when it is actually in $1.

Whilst you may have seen the mistake, users of your library (which includes you at some future point in time) may not see the mistake.

A core principle of API design is to build an API that makes it hard to make mistakes. The above example is just asking for a user to reverse the values.

As a Windows developer, I made this exact mistake very early in my career. A mistake that took close to 6 months duration and around 1 man-month of time to fix. In my case, it was a call to allocate memory in Windows.

HGLOBAL GlobalAlloc(
  DWORD   uFlags,
  DWORD dwBytes
)

The call takes two DWORDs, one the size and the other the type of memory to be allocated. I reversed the two arguments and despite multiple code reviews, it was never picked up but it did cause the stock market system we were building to crash randomly in production!

GlobalAlloc is an example of a badly designed method, largely caused by the limitations of the ‘C’ language calling conventions.

In Dart, we could avoid this type of bug by changing the parameters to named arguments.

HGLOBAL GlobalAlloc(
  {DWORD   uFlags,
  DWORD dwBytes}
)

For further reading on choosing Dart parameters types see:
A guide to choosing Dart parameter types

The same technique can be applied to Records that also support name fields.

({int lat, int long}) latlongB(Address address) =>
    (lat: address.lat, long: address.long);
  final geoB = latlongB(address);
  print('long: ${geoB.lat}');
  print('lat: ${geoB.long}');

I’ve made the same mistake, but a quick review of the code makes the mistake obvious.

So here is the first rule of using Records, don’t use positional fields when returning a Record from a function.

When is clever, too clever?

The following is an example of, not so much an inherent problem with Records, but a solution that is too clever.

The problem with clever solutions is that they are often hard to maintain:

List<(int, T)> rle<T>(List<T> input) => input.fold(
      [],
      (prev, cur) => switch (prev) {
        [...final rest, (final n, final t)] when t == cur => [
            ...rest,
            (n + 1, t)
          ],
        _ => [...prev, (1, cur)],
      },
    );

Can you work out what the function does? It counts contiguous elements in a list and returns a count for each element.

Now imagine you need to debug the function:

The following line has four components:

        [...final rest, (final n, final t)] when t == cur => [
  • A spread operator ...final rest.

  • A Record (final n, final t)

  • A pattern when t

  • A conditional t == cur

See how well you go trying to step the debugger through the function and compare it to the following alternate solution.

So let’s look at an alternate solution:

List<RLE<T>> rle2<T>(List<T> input) {
  final result = <RLE<T>>[];
  RLE<T>? current;

  for (final element in input) {
    if (current == null || current.element != element) {
      current = RLE(element, 1);
      result.add(current);
    } else {
      current.count++;
    }
  }
  return result;
}

class RLE<T> {
  RLE(this.element, this.count);
  T element;
  int count;
  @override
  String toString() => '($count, $element)';
}

We have removed the fold, the spread operator, the pattern match and the record.

So the resulting code is certainly more verbose, but a junior developer can now maintain the code. The code will also run faster as we have removed the overhead of the map function and the constant reallocation of the list.

Creating the RLE class creates a few extra lines of code, but if you look at the entire life cycle of a function, the alternate implementation will cost less because maintenance will be easier and junior devs cost less.

So the clever solution turns out to be slower, as well as harder to maintain.

The augment here is don’t use a feature just because it exists. Use a feature if it makes the code easier to read and maintain.

Multiple return values for errors

The core use case of Records is to return multiple values.
With this example, a community member looked to use Records to return errors from a validation method.

({bool isValid, String errMsg}) validateA(Task task) {
  if (task.title.isEmpty) {
    return (isValid: false, errMsg: 'Title cannot be empty');
  }

  if (task.durationInMilliseconds == 0) {
    return (isValid: false, errMsg: 'Duration cannot be 0');
  }

  return (isValid: true, errMsg: '');
}

  // call the validate method.
  final (:isValid, :errMsg) = validateA(task);
  if (isValid) {
    runTask(task);
  } else {
    print(errMsg);
  }

This technique is trying to borrow concepts from the likes of Rust.

Rust requires you to acknowledge the possibility of an error and take some action before your code will compile.

If the above code was written in Rust the compiler would throw an error if you forgot the if statement.

Dart has no such qualms.

It’s perfectly valid to write:

validateA(task);
runTask(task);

Dart was designed with exceptions as the intended error mechanism. Using records to side-step exceptions has multiple problems.

  • We bifurcate the Dart ecosystem, with some libraries throwing exceptions and some returning errors. This makes coding difficult as you have to change your coding patterns for different libraries.

  • It allows users to ignore the error and failing to check an error can cause serious problems.

/// change the current directory
(int result, String error) cd(String directory) {
}

cd(‘/’); // change directory to the root directory
cd(‘/tmp/non/existent/directory/’); // cd fails silently
deleteRecursive();

You have now managed to do some serious damage to your system because you just asked it to recursively delete everything under root. Yikes!

If instead, we had written:

/// Throws a CDException if unable to change directories
void cd(String directory) {
}

cd(‘/’); // change directory to the root directory
cd(‘/tmp/non/existent/directory/’); // cd throws
deleteRecursive();

Now when I fail to check for an error (the missing catch block), I’ve done no damage to my system and the app probably shuts down with a stack trace.

Again, we need to design our code so it’s as failsafe as possible. A well-designed library makes it easy to use correctly.

Conclusion

When reading numerous posts about using Records, one statement stood out, that typified the drive for Dart supporting Records:

But we always need to define a class and do extra work just for a simple problem, and I feel too lazy to do that sometimes.

As a programmer, I’m all for finding the easiest way forward, but taking shortcuts that cost you more later is a mistake.

We don't need to make every function an answer to a leetcode question.

It’s important that we don’t get caught up in the latest fad and start using language features just because we can. Remember, simple is beautiful, and we don't need to make every function an answer to a leetcode question.

First and foremost we need to focus on writing code that;

  • is easy to maintain

  • is easy to use

  • avoids misuse

Keep it simple.

Brett
The pragmatic programmer.

What we do:

OnePub provides a Dart/Flutter private repository SaaS with a generous free tier.

For larger organisations, OnePub also provides dedicated hosting within your cloud.

Sign up today and improve your team's productivity.