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.
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.
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);
}
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.
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('|'));
}
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.