skip to content

In Dart, when are two records equal, and why can `(1, [2]) == (1, [2])` still be false?

level: middleimportance: should knowfreq 42%

answer

  1. built-in == and hashCode
  2. same shape, then field by field
  3. each field's own == decides
  4. a List compares by identity
  5. immutable fields, mutable contents

basics

~20 s

Records get == and hashCode automatically: equal when they have the same shape and every field is equal by that field's own ==. A List field compares by identity, so two records holding different but identical-looking lists are not equal.

solid answer

~50 s

Dart generates `==` and `hashCode` for every record: two records are equal when they have the same **shape** and each pair of corresponding fields is equal according to the **field value's own `==`**. The comparison is **shallow**. `List` does not override `==`, so lists are only equal to themselves, and `(1, [2]) == (1, [2])` is `false` because the two list literals are different objects; share one list instance, or use `const [2]` in both, and it becomes `true`. The same shallowness applies to immutability: a record has no setters, but a mutable object inside it can still be changed. That makes records good composite map keys, such as `Map<(int, int), Tile>` for grid cells, as long as every field has value-based equality and does not change while used as a key. Never rely on `identical` for records, since they have no persistent identity.

code

dart · 12 lines
dart
void main() {
  final shared = [2];
  print((1, [2]) == (1, [2])); // false: two different lists
  print((1, shared) == (1, shared)); // true: the same list object
  print((1, const [2]) == (1, const [2])); // true: canonical constant

  final cells = <(int, int), String>{(0, 0): 'start', (3, 4): 'goal'};
  print(cells[(3, 4)]); // goal: structural hashCode and ==

  final pair = ('a', double.nan);
  print(pair == pair); // false: nan is not equal to itself
}

go deeper

for a junior

Remember that records compare by their field values out of the box, so (1, 'a') == (1, 'a') is true without writing any ==.

for a middle

Explain that the comparison is shallow and delegates to each field's ==, which is why a List field compares by identity and a const list does not.

for a senior

Use records as composite map keys safely, knowing the traps: identity-equal collections, hash codes that change with mutable state, NaN, and the absence of persistent identity.

for a principal

Decide when structural equality of records is the right default for domain values and when a class with explicit, deep or selective equality should own that decision.

## Equality you get for free Every record automatically has an `operator ==` and a `hashCode`. The rule for `==`: 1. The other object must be a record with the **same shape** — the same number of positional fields and the same set of named-field names. 2. Every pair of corresponding fields must be equal according to the **field value's own `==`**. Named fields are matched by name, so their written order does not matter. `hashCode` is computed from the fields' `hashCode` values, and it is consistent with `==`: equal records have equal hash codes. The exact combination, and the order in which fields are compared, are deliberately unspecified. ```dart print((1, 'a') == (1, 'a')); // true print((a: 1, b: 2) == (b: 2, a: 1)); // true print((x: 1, y: 2) == (r: 1, g: 2)); // false: different named fields ``` ## Shallow, not deep The record delegates to its fields, and stops there. Whether two records are equal depends entirely on what the fields' `==` does: | Field type | Its `==` | `(1, a) == (1, b)` when `a` and `b` look identical | |---|---|---| | `int`, `double`, `String`, `bool` | value-based | `true` | | another record | structural, recursively | `true` if its fields are value-equal | | `List`, `Map`, `Set` | **identity** — only equal to themselves | `false` unless it is the same object | | a `const` list literal | identity, but identical constants are canonicalised | `true` | | your class without `==` | identity | `false` unless the same instance | | your class with `==` | whatever you wrote | depends on your implementation | So `(1, [2]) == (1, [2])` is **false**: each `[2]` creates a new list, and `List`'s `==` is identity. Writing `const [2]` twice gives the same canonical object, so that version is **true**. For deep collection equality you need a class with a custom `==`, or a helper that compares elements. One more edge from the `dart:core` documentation: a field holding `double.nan` makes a record unequal **to itself**, because `nan == nan` is false — `var pair = ("a", double.nan); pair == pair` is `false`. ## Shallow immutability A record has **no setters**: you cannot reassign or add a field. But immutability is shallow in the same way equality is: ```dart final reading = (station: 'north', samples: <double>[12.5]); // reading.samples = []; // compile error: no setter reading.samples.add(13.0); // fine: the list itself is mutable ``` If you need the whole value frozen, put unmodifiable or immutable objects into the fields. ## Records as map keys and set elements Because `==` and `hashCode` are structural, records make natural **composite keys**: - `Map<(int, int), Tile>` indexed by `(row, col)`; - a `Set<({String city, DateTime day})>` that de-duplicates forecasts. This works when every field has value-based equality. It breaks when a field is a `List` (two equal-looking keys never match) or when a field's `hashCode` depends on state that changes after insertion (the entry becomes unreachable). ## What `hashCode` promises — and what it does not The `Record` documentation promises only that `hashCode` is **compatible with `==`** and **consistent within one program execution**. How the field hashes are combined is unspecified, and the order in which fields are compared by `==` is unspecified too. Two practical consequences: - Never **persist** a record's `hashCode` to disk or send it to a server to compare later; a new run, or a different platform, may compute a different number for the same values. - Never write an `==` on a field value that depends on **side effects** or on being called in a particular order; the record may compare fields in any order and may stop early. ## Identity is not part of the deal The `Record` documentation states that records have no persistent `identical` behaviour: a reference to a record may be replaced at any time by another record with the same shape and values. So: - never use `identical(r1, r2)` to reason about records; - do not attach an `Expando` to a record — `dart:core` rejects records there for exactly this reason — or rely on a record's identity for caching. Compare with `==`, which is what the language guarantees.

  • How would you make two records holding lists compare equal by contents?
    The record itself cannot be changed to compare deeply; its `==` always delegates to the fields. Either store values whose `==` is value-based, such as another record or a class with a custom `==` over its elements, or compare the records with a helper that checks the lists element by element.
  • Is it safe to use a record containing a mutable object as a map key?
    Only if that object's `hashCode` and `==` do not change while it is a key. A plain `List` field is stable but compares by identity, so lookups need the same list. A field whose hash depends on mutable state can make the entry unreachable after it changes.

Comparing two records is like checking two parcel manifests line by line: each line must match. If a line says 'locker 12' rather than listing the locker's contents, two manifests match only when they name the same locker, not two lockers that happen to hold the same things.

saying these in an interview costs you the question

  • Thinks record equality compares list fields element by element
  • Believes records compare by identity like ordinary objects
  • Uses identical to check whether two records are the same
  • Says a record's contents can never change because it is immutable
  • Assumes named-field order affects record equality