Dart - Using Records Type Examples

Dart 3 introduced a new feature called records. In this tutorial, I am going to explain how to use records in Dart.

Records can be defined as an anonymous type that's immutable. A record can have multiple fields and each field is typed. The type of a field can be different from other fields. Therefore, they can be used to store multiple values with different types. Compared to a class, creating a record requires less ceremony. In addition, with the ability to have different types on each field and its immutable behavior, they are preferred over collection types such as List or Set in some situations.

Record Declaration

A record has zero or more fields separated by comma, enclosed in parentheses. To express a record value, create a parentheses () and put the fields inside. Like function parameters in Dart, the fields can be positional or named. For named fields, the syntax is name: value.

  // positional only
  var pairAlt = (1, 2);

  // named only
  var point = (x: 1, y: 2);

  // positional and named
  var person = ('001', 'Ivan', nickname: 'Cute', isActive: true);

When creating a record value, the order of the positional field matters. For named fields, you can put the values in any order. The person variable above can be rewritten as the following.

  var person = (nickname: 'Cute', isActive: true, '001', 'Ivan');
  var person = ('001', nickname: 'Cute', 'Ivan', isActive: true);

Dart supports type inference. If a variable is declared using var or final keyword, like the examples above, the compiler will infer the declared type of the variable. However, it's also possible to explicitly declare the type. To do it, you have to understand the syntax of record type annotation.

Record type annotation also uses parentheses. It can be used in variable declaration, return types, and parameter types.

For positional fields, it consists of the list of types separated by comma. Each type can be followed by an optional name. The name added to the type of a positional field cannot be used for accessing the field and it doesn't affect the type of the record. The only usage is for documentation to improve readability.

Named fields are put as comma-delimited values inside a curly bracket. The curly bracket must be put after the types of all positional fields. Each named field type declaration consists of the type followed by the name.

In general, the syntax is similar to declaring function parameters in Dart, except the names of the positional fields are optional.

  (int, int) pair = (1, 2);
  (int, int) pairAlt = (1, 2);
  (int, int) pairAlt2 = (1, 2);

  ({int x, int y}) point = (x: 1, y: 2);
  ({int y, int z}) pointAlt = (y: 1, z: 2);

  (String, String, {String nickname, bool isActive}) person = ('001', 'Ivan', nickname: 'Cute', isActive: true);
  (String id, String name, {String nickname, bool isActive}) personAlt = ('001', 'Ivan', nickname: 'Cute', isActive: true);

Record Shape and Type

The number of positional fields and the name of named fields determines the shape of a record. Meanwhile, the type of a record is determined by the shape and the runtime type of each field. The type can be obtained from the runtimeType property.

  print(pair.runtimeType);
  print(pairAlt.runtimeType);
  print(pairAlt2.runtimeType);

  print(point.runtimeType);
  print(pointAlt.runtimeType);

  print(person.runtimeType);
  print(personAlt.runtimeType);

Output:

  (int, int) // pair
  (int, int) // pairAlt
  (int, int) // pairAlt2
  ({int x, int y}) // point
  ({int y, int z})  // pointAlt
  (String, String, {bool isActive, String nickname}) // person
  (String, String, {bool isActive, String nickname}) // personAlt

From the output, we can see that pair, pairAlt, pairAlt2 all have the same type. That's because the name of the positional fields are ignored by Dart. The same also applies for person and personAlt.

We can also see that the point and pointAlt have different shapes, and therefore different type. Two records whose all the fields have the same type can be considered to have a different shape if there is any named field with different name.

In addition, all records are a subtype of the Record class. The members of the class include:

  • Type get runtimeType: The runtime type which is determined by the shape and the runtime type of each field.
  • int get hashCode: Hash-code computed based on the Object.hashCode of the field values. It's consistent throughout a single program execution
  • operator ==(Object other): Check whether other has the same shape and equal fields.
  • String toString(): String representation, only for debugging in development. Dart doesn't guarantee the format in production mode.

Record Fields

Named fields can be accessed by using their name. For positional fields, Dart exposes getters whose syntax is $positionIndex. The index itself starts from 1.

  print(person.nickname);
  print(person.isActive);
  print(person.$1);
  print(person.$2);

Output:

  Cute
  true
  001
  Ivan

If you try to access an index that's greater than the number of positional fields, you'll get an error.

  lib/example.dart:27:16: Error: The getter '$3' isn't defined for the class '(String, String, {bool isActive, String nickname})'.
  Try correcting the name to the name of an existing getter, or defining a getter or field named '$3'.
    print(person.$3);

Records are immutable. Therefore, fields do not have setters since the value cannot be reassigned.

There are a some rules regarding naming

  • Names used as the members of Record class (hashCode, runtimeType, toString and noSuchMethod) cannot be used.
  • Names cannot start with _ (underscore) because fields cannot be private.
  • Names of named fields cannot be the same as the getters of positional fields. For example, (0, $1: 'a') is invalid because $1 is already used as a getter. On the other hand, (0, $2: 'a') is valid if there is only one positional field.

Nested Record

It's also possible to have a record whose field type is another record.

  var smartphone = (brand: 'Nokia', memory: 12, processor: (name: 'SnapTek', frequency: 3000));
  print(smartphone.runtimeType);

Output:

  ({String brand, int memory, ({int frequency, String name}) processor})

Record Equality

You can compare two records by using the equality operator. There are two criteria to determine whether two records are equal. First, they must have the same shape. Second, the values for each field must be equal.

  (int, int) pair1 = (1, 2);
  (int, int) pair2 = (1, 2);
  (int, int) pair3 = (2, 2);
  print('pair1 == pair2: ${pair1 == pair2}');
  print('pair1 == pair3: ${pair1 == pair3}');
  pair1 == pair2: true
  pair1 == pair3: false

In the example above, all variables have the same shape. pair1 and pair2 have the same values for each field. As a result, the comparison returns true. For pair3, the value of the first field doesn't equal to pair1's first field. Therefore, the comparison returns false.

  ({int a, int b}) point1 = (a: 1, b: 2);
  ({int x, int y}) point2 = (x: 1, y: 2);
  print('point1 == point2: ${point1 == point2}');
  point1 == point2: false

Usage in Function

For example, you have a function that has to return multiple values. You can create a class to define the return type, but declaring a class is too verbose. Another alternative is using a List or a Map. However, if the return values have different types, you cannot have type safety.

For that situation, using a record as the return type can be a good solution. It's less verbose than a class, but still allows you to define the type for each value.

  (int, int) createPair() {
    final random = Random();
    return (random.nextInt(10), random.nextInt(10));
  }

Not only as a return type, you can also use records as a function parameter.

  (int, int) swap((int, int) pair) {
    var (a, b) = pair;
    return (b, a);
  }

Summary

In this tutorial, we have learned about records in Dart. Basically, they are an immutable type that consists of several fields, either positional or named. Records can be stored in variables, used as function parameters, and declared as function return types. In addition, it's also possible to have nesting records structure.

This feature is no longer an experimental feature, so you don't need to add the experimental flag when running the code.

You can also read about: