skip to content

When should a Dart API use a record, or a record typedef, instead of a class, and what signals that it is time to switch?

level: seniorimportance: should knowfreq 40%

answer

  1. data only, no behaviour
  2. a typedef is just an alias
  3. no validation, no encapsulation
  4. same shape means same type
  5. a class is the only full abstraction

basics

~20 s

Use a record for small, data-only bundles with a local or private reach: multiple returns, composite keys, lists of simple rows. Switch to a class when the data needs invariants, behaviour, encapsulation, or a distinct type that same-shaped values cannot impersonate.

solid answer

~40 s

A record is right when you only need to carry data: returning two values, a composite map key, a list of simple rows such as button definitions, or a private helper. It needs no declaration, and structural `==` and `hashCode` come free. A `typedef` gives a repeated record type a name, but it is **only an alias**: it adds no type safety and any same-shaped record fits. Move to a class when the bundle needs **invariants** (a record cannot validate its fields), **behaviour** beyond extension methods, **encapsulation** (all record fields are public), **nominal typing** so a `(double, double)` for latitude and longitude cannot be mixed up with a min/max pair, or a stable public API you want to evolve. The dart.dev guidance itself says only a class provides full abstraction and encapsulation.

code

dart · 26 lines
dart
typedef Range = ({double min, double max});

// Structural: any ({double min, double max}) is a Range.
Range temperatureRange(List<double> readings) {
  final sorted = [...readings]..sort();
  return (min: sorted.first, max: sorted.last);
}

// When the pair needs a rule and behaviour, it becomes a class.
class TemperatureRange {
  TemperatureRange(this.min, this.max)
      : assert(min <= max, 'min must not exceed max');

  final double min;
  final double max;

  double get spread => max - min;
  bool contains(double t) => t >= min && t <= max;
}

void main() {
  final r = temperatureRange([12.5, 9.5, 17.25]);
  final range = TemperatureRange(r.min, r.max);
  print(range.spread); // 7.75
  print(range.contains(20)); // false
}

go deeper

for a junior

Know that a record is for bundling data without writing a class, and that a class is needed as soon as you want methods.

for a middle

Explain what records cannot do: no validation, no private fields, no methods except extensions, and a typedef that is only an alias.

for a senior

Show the judgment: records for local, data-only bundles; classes when the value is a concept with rules, crosses a package boundary, or could be confused with another same-shaped value.

for a principal

Set the team rule for records in public package APIs, weighing their zero-declaration convenience against the breaking-change cost of structural types and the lack of encapsulation.

## What a record gives you A record is Dart's lightest way to group values: - **No declaration.** `({double min, double max})` can be written straight into a signature. - **Typed fields.** Each field keeps its static type. - **Structural `==` and `hashCode`.** Equality and hashing come for free, based on the fields. - **Immutable fields.** There are no setters, although the objects in the fields can still be mutable. That makes records the natural choice for a handful of situations. ## Where a record wins 1. **Multiple return values** from a function, especially private helpers. 2. **Composite keys**, such as `Map<(int, int), Tile>` for grid cells. 3. **Simple rows of data** that all share one shape, such as a list of button definitions with a label, an icon and an `onPressed` callback — the dart.dev records page uses exactly this Flutter example. 4. **Short-lived intermediate values** inside one library, such as the result of a parsing step before a real model is built. 5. **Combining futures of different types**, where `dart:async` offers `wait` on a record of futures and gives back a record of results. ## A typedef is a name, not a type When the same record type appears in several places, a **typedef** keeps the code readable: ```dart typedef ButtonItem = ({String label, Icon icon, void Function()? onPressed}); final List<ButtonItem> buttons = [/* ... */]; ``` Because record types are structural, `ButtonItem` is only an **alias** for the record type. It does not create a new type: any `({String label, Icon icon, void Function()? onPressed})` from anywhere is a `ButtonItem`. Its value is that code refers to one name, so changing the representation later — to a class or an extension type — touches fewer places. The dart.dev page warns that such a change still requires care, because an alias gives the code using it no guarantee about what is behind it. ## What a record cannot do | Need | Record | Class | |---|---|---| | Validate fields when created | no — any values of the right types are accepted | constructor asserts, factories, checks | | Methods and computed properties | only through extension methods on the record type | yes | | Private state | no — every field is a public getter, and names cannot start with `_` | yes | | A distinct type for a concept | no — every same-shaped record is the same type | yes, nominal | | Stable `toString` output | no — format is unspecified outside development builds | you control it | | Documentation per field | limited to names in the type | full doc comments | The **nominal typing** row is the one that bites in real code. A `(double, double)` for latitude and longitude and a `(double, double)` for a min/max range are the **same type**; a function expecting one silently accepts the other. Named fields help — `({double lat, double lng})` and `({double min, double max})` are different types — but any other code that happens to use `lat` and `lng` still fits. ## Signals that it is time to switch - The record appears in a **public API** of a package others depend on, and you expect to add fields — adding a field to a record changes its type everywhere. - You find yourself writing the **same validation** before creating the record in several places. - **Extension methods** on the record type are piling up; that is a class trying to get out. - Values of the same shape but different **meaning** are being passed around and have already been mixed up. - You need **`copyWith`**, a readable `toString`, or serialisation hooks attached to the type. Extension types sit in between: they wrap a record in a named static type with no wrapper object, but they are erased at run time and, as the dart.dev page notes, offer little protection. A class remains the only full abstraction. ## A practical rule Start with a record for **local, data-only** bundles and for the first draft of a helper. Promote it to a **class** as soon as the value crosses a module boundary as a concept of its own, needs rules about what values are valid, or needs behaviour. The cost of promotion is small when every use already goes through a typedef.

  • Can you add methods to a record type at all?
    Only through extension methods declared on the record type, such as an extension on `({double min, double max})`. `dart:async` does this itself, with extensions like `FutureRecord2` that add `wait` to a record of two futures. The methods are statically resolved and add no state, so they are no substitute for a class with invariants.
  • Why is adding a field to a record in a published package's API a breaking change?
    A record's shape is its type, so `({double min, double max})` and `({double min, double max, String unit})` are unrelated types. Every caller that declared the old type, built the record or destructured it stops compiling. A class can usually gain an optional field without breaking callers.

saying these in an interview costs you the question

  • Believes a typedef makes a new distinct record type
  • Thinks a record constructor can validate its fields
  • Uses positional (double, double) for several unrelated concepts in one API
  • Assumes a record's toString is a stable format to parse or log
  • Claims records can hold private fields named with an underscore