Sparround

Equality and immutability

In Dart the default == is reference equality: two objects are equal only when they are the same instance. That is why User('Ayan') == User('Ayan') is false.

Distinguish three concepts:

  • identical(a, b)is it the same object? A reference comparison that cannot be overridden.
  • a == blogical equality, which a class may override.
  • a.hashCode — an integer for hash-based collections (Set, Map keys).

The contract is simple, and breaking it leads to silent bugs:

  • If a == b is true, then a.hashCode == b.hashCode must be true
  • Equal hash codes do not imply == (collisions are normal)
  • == must be reflexive, symmetric, transitive and stable

The most common mistake is overriding == and forgetting hashCode. The code compiles (with only a lint warning), but the object appears twice in a Set and cannot be found in a Map — because the collection consults the hash first.

ApproachWhat it givesCost
Default (no override)Reference equalityUseless for value objects
Hand-written `==` + `hashCode`Full controlEasy to forget when a field is added
`Object.hash(a, b, ...)`Correct hash combinationStill hand-written
RecordStructural equality for freeNo name and no methods
`package:equatable`Automatic from a list of fieldsA dependency; the `props` list is still manual
`freezed` (code generation)`==`, `hashCode`, `copyWith`, `toString`A build runner and generated files

A value object is a type defined by its value: Money(100, 'AZN'), Coordinates(40.4, 49.8), EmailAddress('[email protected]'). Three things come together for such types: overriding ==/hashCode, immutability (final fields), and a const constructor where possible.

Why immutability? Because a mutable object's hashCode can change. If an object was added to a Set and then one of its fields changed, it stays in its old bucket and set.contains(obj) returns `false` even though the object is in there. This is one of the hardest bugs to track down.

A `const` constructor adds canonicalisation on top: const Money(100, 'AZN') is a single object across the program, so identical(const Money(100, 'AZN'), const Money(100, 'AZN')) is true. The comparison then resolves at the reference level without ever entering the == body.

The requirements for a const constructor: all fields final, an empty body, and every field value a compile-time constant.

The `copyWith` pattern is the standard way to work with an immutable model: instead of mutating the object, you create a modified copy.

The naive implementation has a well-known problem: in the signature copyWith({String? name}), null means two different things — "leave this field alone" and "set this field to null". For nullable fields that ambiguity is a real bug. The fixes: a sentinel object (Object? name = _unset), or the generated copyWith from the freezed package.

Why does Flutter lean on immutability? Because the rebuild cycle is comparison-based: the framework compares the old and new configuration to decide what changed. That comparison is only cheap and correct when the objects cannot change — a mutable object can produce "same reference, different contents", and the change is lost. A const constructor lowers the comparison to the identical() level. The same logic applies to state management: whether a new state object differs from the old one is decided by ==.

Interview tip. "You overrode ==; why must you also override hashCode?" — answer with the mechanism: "`Set` and `Map` first locate a bucket by `hashCode` and only then call `==`. If the hashes differ, `==` is never called — an equal object shows up twice in a set and cannot be found in a map." This is not a theoretical rule but a concrete bug, and it is exactly what the interviewer wants to hear.

The second classic: "what is the difference between == and identical()?" — the short answer: "`identical` tests whether it is the same object and cannot be overridden; `==` is logical equality and can be. Because `const` objects are canonicalised, `identical` is also `true` for the same `const` expression."

📚 Sources and documentation