Course outline

Dart / Integration: targets, resources, validation, and evidence / Lesson 16

Build value models with stable equality and snapshots

Implement equality and hashCode together, keep keys stable, and choose explicit copy and unmodifiable-container policies.

About 5 min readContent reviewed
On this page

Lesson goals

  • Explain why final fields and identical-looking objects do not automatically provide value equality.
  • Review equality, hashing, copying, and nested mutation as one data-model contract.

Choose what makes two instances the same value

Ordinary Dart class instances use identity-based equality unless the class defines another contract. Two separately constructed records of an application concept can therefore look alike in a debugger without comparing equal. Decide which fields define the value: a sample identifier and revision may form a key, while a transient display caption should not. Dart records provide structural equality using their fields’ equality contracts, but ordinary lists do not automatically compare element-by-element. Choose the intended model explicitly rather than assuming a final declaration or a generated-looking constructor supplies deep equality.

Equality and hashing must agree

If two objects compare equal, they must have equal hashCode values. The reverse does not hold: a hash collision is possible and is handled by the collection’s equality logic. The key below uses stable string and integer fields and Object.hash. It prints equality relationships, not a specific numeric hash, because hash values are not durable business identifiers and need not remain stable across executions or platform versions. A custom equality implementation must preserve its required properties, including symmetry and transitivity. Extensible value hierarchies need particular care when subclasses add fields or alter equality.

dart A stable value key and consistent hash contract
final class SampleKey {
  final String id;
  final int revision;
  const SampleKey(this.id, this.revision);
  @override
  bool operator ==(Object other) =>
      other is SampleKey && other.id == id && other.revision == revision;
  @override
  int get hashCode => Object.hash(id, revision);
}
void main() {
  final first = SampleKey('S-4', 1);
  final equalValue = SampleKey('S-4', 1);
  print(first == equalValue);
  print(first.hashCode == equalValue.hashCode);
  print(<SampleKey>{first, equalValue}.length);
}
Expected output
true
true
1

Expected output is true, true, 1. Equal keys occupy one set entry. No particular hash number is predicted, and this source was not compiled or executed.

A mutable hash key can invalidate lookup assumptions

Changing a field used by equality or hashCode after insertion can make a hash-based collection behave inconsistently with the caller’s expectations. The collection placed the object using its earlier hash, while later lookup observes a different value contract. Do not repair this with retries or by treating lookup failures as random infrastructure faults. Use immutable value keys or remove and reinsert under a deliberate collection policy when mutation is genuinely required. A final field holding a mutable list is still risky if equality depends on that list’s changing contents through a custom comparison.

Copy the container according to its ownership promise

The snapshot constructor creates an unmodifiable list from supplied labels. Later additions to the original list do not affect that list structure. The changed copy builds a new label sequence instead of editing the old snapshot. Strings make this example straightforward; mutable element objects would still require their own ownership or copy policy. The class does not override equality, so these snapshots are not automatically equal by contents. Keep isolation of contents separate from equality of instances, and explain both when exposing a model to caches, state holders, or other callers.

dart An explicit snapshot and non-mutating update
class BatchSnapshot {
  final String id;
  final List<String> labels;
  BatchSnapshot(this.id, Iterable<String> labels)
      : labels = List<String>.unmodifiable(labels);
  BatchSnapshot withLabel(String label) =>
      BatchSnapshot(id, <String>[...labels, label]);
}
void main() {
  final source = <String>['raw'];
  final first = BatchSnapshot('B-3', source);
  source.add('external');
  final second = first.withLabel('reviewed');
  print(first.labels.join('|'));
  print(second.labels.join('|'));
}
Expected output
raw
raw|reviewed

Expected output is raw then raw|reviewed. The external source addition is absent from both snapshots because the first constructor copied its list structure. The program was not run.

Validate the model at the boundary where it is trusted

Immutability does not prove that a count is positive, an identifier is authorized, or a revision refers to a real record. Validate those rules separately. Avoid using toString output or hashCode as a serialized identity; both serve different purposes from a stable wire code. Check alias mutation, equality of distinct instances, unequal values, and hash consistency in an isolated project with the actual SDK. If a model crosses isolates or JSON boundaries, review transfer and decoding rules independently. No equality test runner or mutation experiment was executed here; the examples are reasoned teaching programs.

Common errors and fixes

Equal objects produce different hashes.

Why it happens
The == override was not paired with a consistent hashCode implementation.
How to fix it
Use the same value-defining fields for both contracts.
Prevent it next time
Check equal distinct instances and hash consistency together.

A final list field changes through a source alias.

Why it happens
The model stored a mutable shared container without a snapshot policy.
How to fix it
Copy or wrap using the intended API and review element mutability.
Prevent it next time
Test source mutation after model construction.

A persisted hash no longer identifies the expected record.

Why it happens
A runtime hashing aid was used as a durable business identity.
How to fix it
Persist an explicit stable identifier and validate it at the boundary.
Prevent it next time
Keep equality helpers separate from storage and wire identifiers.

Check your understanding

1. Does matching hashCode prove two objects are equal?

Show worked solution

No. Distinct values can collide. A hash-based collection uses equality as well as hashing to distinguish entries. The required direction is that equal values have equal hashes, not that every equal hash implies equal value. Never use hashCode alone as an authorization decision, unique key, or durable fingerprint.

2. A snapshot contains mutable sample objects. Is copying its outer list sufficient?

Show worked solution

Only if the contract permits later changes inside those shared elements. Otherwise use immutable element values, an explicit deeper copy, or exclusive ownership. State what identity must be preserved and how resource-bearing elements are handled. A generic deep-copy slogan cannot decide those domain and lifecycle questions.

Key takeaways

  • Value equality, hash consistency, and stable keys form one contract.
  • A snapshot must explain both container and element ownership.

Official references