My Best Advice For Dart 3.0 Records

Matt
6 min readMay 25

--

If you want to use records in Dart 3.0 and not feel the dreaded technical debt, then please take into consideration the following:

typedef ErrorResponse = ({int errorCode, String message});

void main() {
ErrorResponse? error = request();
}

ErrorResponse? request() {
return (errorCode: 418, message: 'I\'m a teapot');
}

When I first started playing with records, I wanted to see if typedefs worked. When it didn’t bring up any syntax errors, I knew I struck gold.

Typedef Magic

typedefs, which is Dart’s system for defining type aliases, is a very useful way to avoid repeatedly writing extremely long types. They allow the centralization of type definitions that can then be used throughout a program. If you wanted to change how data is structured or ordered, you can make a centralized change that applies everywhere. This can save time as you won’t get caught jumping from function to function as types change, especially for records.

Using a typedef can also make other important aspects more obvious. From the example, the return type being nullable is very clear and stands out. The request might not always return an error and instead have a null case (You would want to clarify this with doc comments). It is also clear what the record is for, but we could even go further and name it HttpErrorResponse and also give it a doc comment. The following would be this more verbose version:

/// An HTTP error with a status code and a message.
typedef HttpErrorResponse = ({int errorCode, String message});

void main() {
HttpErrorResponse? error = request();
}

/// Returns `null` if the request was successful
HttpErrorResponse? request() {
return (errorCode: 418, message: 'I\'m a teapot');
}

When it comes to aliases for records/tuples, Dart has impressed me. It is still something that C# has not implemented with it getting an alternative solution as of C# 10. Fun fact, these record classes in C# are also being discussed for Dart in a proposal for data classes. With Dart 3.0 leaning into many different class modifiers, I feel like this would fit right in.

The records along with typedefs in Dart 3.0 make a great pairing that I wish other languages had. They give the ability to write much more concise code without the need for making many classes. You can also still leave comments on the typedef just in case you forget what something means.

Use Named Fields

Now it is time for the more opinionated part: always prefer using named fields for records. There are scenarios where the data is simple enough to not warrant naming, but even adding simple names can save a lot of thinking in the future. Even destructuring becomes much safer to do. Mixing up an x and a y can create logic bugs that can be easily overlooked and hard to debug.

When it comes to positional fields, I have a few more thoughts on the matter . . .

The Syntax . . .

The .$ syntax has got to be one of the strangest additions to the Dart syntax. The other place this symbol appears in string interpolations for including values. The only thing going for .$ is that one use for strings and the fact that it is was already a valid identifier, like var $a. Though, I am not sure if it being a valid identifier makes it a better choice. It very well could have been .#, which is in a way more expressive.

One of the clear improvements of adding records is to avert passing around lists of objects. When accessing elements in lists, the well-known bracket ([]) operator is used. At first glance, you might have the knee-jerk reaction of “Why didn’t they just use that!”. There was some discussion about how that wasn’t going to pan out, and they settled on going with .$0 for accessing positional arguments. Now you might notice something a bit strange about that, but let me first add a few other thoughts I have.

One alternative to using [] would be to go with .[]. I don’t know how game-breaking that would be as I am not a language engineer, but before you think too much about it, there is a more prominent issue with this approach. When using this bracket operator/syntax, languages naturally indicate any number and even variables can be passed in to dynamically get elements (Or other types in the cases of keys for maps). But, if there is one thing Dart has been trying to avoid, it is dynamic and unknown types. The final syntax chosen enforces that the number given is valid, and Dart can ensure the returned type. Who knew so much could be said when it comes to just the syntax decisions!

Not Starting at Zero

Now, let’s get back on track with what I had mentioned prior: going with .$0 to access positional fields. It was the syntax up until a GitHub issue was opened to reconsider this: https://github.com/dart-lang/language/issues/2638. What I find interesting about this thread is how the final decision was informed. Just from the initial reactions to the issue, you would see that developers favor the “start at zero” mindset. But, there was one developer who rose to the occasion and did a little scraping. They went through Dart packages and other Dart codebases and counted the instances where sequences of the same named parameters started at one versus zero. Their results are quite detailed: https://github.com/dart-lang/language/issues/2726#issuecomment-1379740674.

The following is their conclusion:

While there are many sequences that start with zero, they are heavily concentrated in a few packages like ffigen and realm. When you consider each package as a single vote for a given style, then there is a much larger number of packages that contain one-based sequences. If you look at them, each one-based package only has a fairly small number of sequences. But there are many of these packages. That suggests that most users hand-authoring type parameter and parameter sequences prefer starting them at one.

Based on that, I think we should start positional record field getters at one too.

The results presented and the conclusion drawn are very compelling, but I also think there is more to this problem and the data collected. In the script itself, the following RegEx was used: ^([a-zA-Z_]+)([0–9]+)$. If you have spent time understanding this arcane magic, you would know that this requires whatever word you are looking at to end in a number.

When I code, I do not put a zero suffix at the end of every name I first write. It is usually more organic and comes from me running into an unexpected new value that is required. For example:

typedef ErrorResponse = ({int errorCode, String message});

void main() {
ErrorResponse? response = request();
ErrorResponse? response1 = request();
}

ErrorResponse? request() {
return (errorCode: 418, message: 'I\'m a teapot');
}

I find myself usually defaulting to this style when I am iterating and working through a problem. (I am also aware that these are not parameters or type parameters, but naming is a shared process). I ultimately find it unnecessary to add the zero case.

Going back to the RegEx, it would miss the first case of the name where there is no number included at the end. This could skew results. There is also another case where numbers, or even the $ symbol, appearing between characters would cause the entire name not to match and be included in the dataset even if it ended with a number.

To Wrap It Up

While we could delve into this further, .$1 does not break anything or make anything less functional. I do think there are better practices for naming conventions than just adding numbers to the end when it comes to variable naming, but we are also specifically looking at parameters and type parameters which require static naming and strong assurances for what they refer to. What Dart 3.0 has given is a functional and flexible system for records.

Hopefully, you have been able to learn something new and find the best of what Dart can offer. There are a few topics that I could get a bit more into, like proper naming, but I do not want to stray too far from the main entertainer of this post: records!

--

--