Here is a Set with one element. One assignment later, contains stops finding it:
final attrs = <String, Object?>{'page': 1};
final tag = Tag('intro', attrs);
final seen = {tag};
print(seen.contains(tag)); // true
attrs['page'] = 2;
print(seen.contains(tag)); // false
print(identical(tag, seen.first)); // true
The object is in the set. identical confirms it is the same object. seen.contains(tag) says it isn't there. All I did in between was change a map I had a reference to.
I shipped this aliasing bug three times across two of my own packages before I caught it — once with the map feeding hashCode, exactly the version above — so this is not a contrived puzzle. It is what happens when a value type puts a mutable field into its hashCode.
Why the set loses it
Set is a hash set. When you add an object, it computes hashCode, picks a bucket, and drops the object in. When you later ask contains, it computes hashCode again, goes to that bucket, and looks. If the two hashes differ, it looks in the wrong bucket and finds nothing.
That's fine as long as hashCode never changes while the object is in the set. It is the one rule hashing depends on.
Here is the Tag that breaks it:
class Tag {
Tag(this.name, this.attrs);
final String name;
final Map<String, Object?> attrs; // stored by reference
@override
bool operator ==(Object other) =>
other is Tag && name == other.name && _mapEquals(attrs, other.attrs);
@override
int get hashCode => Object.hash(name, _mapHash(attrs));
}
// Element-wise, one level deep: each value compares and hashes with its own
// == and hashCode. package:collection's MapEquality does the same thing.
bool _mapEquals(Map<String, Object?> a, Map<String, Object?> b) {
if (a.length != b.length) return false;
for (final k in a.keys) {
if (!b.containsKey(k) || a[k] != b[k]) return false;
}
return true;
}
int _mapHash(Map<String, Object?> m) {
var h = 0;
for (final e in m.entries) {
h ^= Object.hash(e.key, e.value); // xor: order-independent
}
return h;
}
attrs is final, so it looks immutable. It isn't. final freezes the reference, not the map it points at. The constructor stored the exact map the caller passed, and the caller still has it. When they write attrs['page'] = 2, they are writing into the same map tag.attrs reads. hashCode folds attrs in, so the moment the map changes, tag's hash changes, and the bucket the set filed it under no longer matches the bucket contains computes. The object is sitting in the set, in a bucket the lookup never visits.
The final is the tell that makes this easy to miss. It reads as a guarantee of immutability, and for an int or a String it is one. For a Map or a List it guarantees only that you won't reassign the field.
Two ways to be correct
The rule is narrow: anything that feeds hashCode must not change while the object could be in a hashed collection. There are two honest ways to satisfy it.
The first is to not put mutable state in equality at all. If Tag is a handle to something — it owns a resource, or it wraps a big buffer you mutate in place — then value equality is the wrong model, and identity equality (the default, no == override) is correct. Two handles are the same when they are the same object, and nothing can drift.
The second, when the type really is a value and callers will compare it, is to make the state actually immutable — copy it in, and hand back something no one can write to:
class Tag {
Tag(this.name, Map<String, Object?> attrs)
: attrs = Map<String, Object?>.unmodifiable(attrs);
final String name;
final Map<String, Object?> attrs;
// == and hashCode unchanged
}
The constructor now takes the caller's map as a parameter and stores a separate, unmodifiable copy. The caller can do whatever they like to their map; it's not the one Tag holds. And tag.attrs['page'] = 3 throws instead of silently corrupting the object. Rerun the first snippet against this version and contains stays true.
Map.unmodifiable copies the top level, which is what the hash reads, so that is where it has to be. If you nest a mutable List or Map as a value inside attrs, that inner collection is still shared and still mutable, so either document that equality compares those by identity or freeze them too. Be explicit about which; a half-frozen value type is its own trap.
Where this actually bites
Nobody writes attrs['page'] = 2 right after building a Tag on purpose. It happens at a distance. A store keeps the objects in a Set or uses them as Map keys, hands one back to you, and you update its metadata — reasonable, the field is right there — and now a Set somewhere else silently can't find it. Or a builder reuses one map across several objects, filling it in between, and they all end up sharing state. The failure surfaces far from the mutation, as a lookup that misses or a deduplication that doesn't dedupe, with no error to trace back.
The check is quick. For every type in your public API that overrides ==, ask what its hashCode reads, and whether any of it can change after construction. If it can, either drop value equality or copy the mutable parts in. Add one test that puts an instance in a Set, mutates the collection you handed the constructor, and asserts the instance is still found — the assertion is one line and it fails loudly the day someone reintroduces the reference.

Top comments (0)