Dart Object-Oriented Programming

Object Equality & Hashcode

50 min Lesson 14 of 24

Understanding Equality in Dart

In Dart, there are two ways to check if two objects are “the same”: the == operator and the identical() function. Understanding the difference is critical for writing correct, bug-free code -- especially when using objects as keys in Maps or elements in Sets.

  • == (equality operator) -- Checks if two objects are logically equal (same value). By default, it checks reference identity, but you can override it.
  • identical(a, b) -- Checks if two variables point to the exact same object in memory. This cannot be overridden.

Default Equality Behavior

class Point {
  final int x;
  final int y;
  Point(this.x, this.y);
}

void main() {
  final p1 = Point(3, 4);
  final p2 = Point(3, 4);
  final p3 = p1;

  // Default == checks reference identity (same as identical)
  print(p1 == p2);          // false -- different objects in memory
  print(p1 == p3);          // true  -- same reference
  print(identical(p1, p2)); // false -- different objects
  print(identical(p1, p3)); // true  -- same object

  // Strings and numbers have built-in value equality
  print(42 == 42);           // true
  print('hello' == 'hello'); // true
}
Key Insight: Without overriding ==, two objects with identical field values are still considered not equal because they occupy different memory locations. This is almost never what you want for value objects like points, colors, money, or coordinates.

Overriding == and hashCode

To define value equality for your class, you must override both operator == and hashCode. These two are a contract -- they must always be overridden together.

Proper Equality Override

class Point {
  final int x;
  final int y;

  const Point(this.x, this.y);

  @override
  bool operator ==(Object other) {
    // 1. Check if same reference (fast path)
    if (identical(this, other)) return true;

    // 2. Check type and field values
    return other is Point && other.x == x && other.y == y;
  }

  @override
  int get hashCode => Object.hash(x, y);

  @override
  String toString() => 'Point($x, $y)';
}

void main() {
  final p1 = Point(3, 4);
  final p2 = Point(3, 4);
  final p3 = Point(5, 6);

  print(p1 == p2);  // true  -- same values!
  print(p1 == p3);  // false -- different values
  print(identical(p1, p2));  // false -- still different objects in memory
}
Warning: The == operator parameter must be Object, not your specific class type. Writing bool operator ==(Point other) will cause a compilation error because the signature must match Object.operator ==.

The Contract Between == and hashCode

There is an unbreakable rule in Dart (and most programming languages):

  • If a == b is true, then a.hashCode == b.hashCode must also be true.
  • The reverse is NOT required -- two unequal objects can have the same hash code (called a collision).

Breaking this contract causes catastrophic bugs with Set, Map, HashMap, and HashSet -- your objects will seem to “disappear” from collections.

What Happens When You Break the Contract

// BAD: Overrides == but NOT hashCode
class BrokenPoint {
  final int x;
  final int y;
  BrokenPoint(this.x, this.y);

  @override
  bool operator ==(Object other) {
    return other is BrokenPoint && other.x == x && other.y == y;
  }

  // hashCode NOT overridden -- uses default (identity-based)
}

void main() {
  final p1 = BrokenPoint(3, 4);
  final p2 = BrokenPoint(3, 4);

  print(p1 == p2);  // true -- they are equal

  // But in a Set...
  final set = {p1};
  print(set.contains(p2));  // false!! The Set cannot find p2
                            // because p2.hashCode != p1.hashCode

  // Same problem with Map keys
  final map = {p1: 'origin'};
  print(map[p2]);  // null!! Cannot find the key
}
How Sets and Maps Work: When you add an object to a Set or use it as a Map key, Dart first checks its hashCode to find the right “bucket,” then uses == to confirm the match. If hashCode gives different values for equal objects, Dart looks in the wrong bucket and never finds the match.

Using Object.hash() and Object.hashAll()

Dart 2.14+ provides convenient helper methods for generating proper hash codes from multiple fields:

Hash Code Generation Methods

class Student {
  final String name;
  final int age;
  final String email;
  final List<String> courses;

  const Student(this.name, this.age, this.email, this.courses);

  @override
  bool operator ==(Object other) {
    if (identical(this, other)) return true;
    if (other is! Student) return false;
    if (other.name != name || other.age != age || other.email != email) {
      return false;
    }
    // Deep list comparison
    if (other.courses.length != courses.length) return false;
    for (int i = 0; i < courses.length; i++) {
      if (other.courses[i] != courses[i]) return false;
    }
    return true;
  }

  // Object.hash() for a fixed number of fields
  @override
  int get hashCode => Object.hash(name, age, email, Object.hashAll(courses));

  @override
  String toString() => 'Student($name, age: $age)';
}

void main() {
  final s1 = Student('Alice', 20, 'alice@email.com', ['Math', 'CS']);
  final s2 = Student('Alice', 20, 'alice@email.com', ['Math', 'CS']);

  print(s1 == s2);                // true
  print(s1.hashCode == s2.hashCode);  // true

  // Works correctly in Sets and Maps
  final students = {s1};
  print(students.contains(s2));   // true -- found!

  final grades = {s1: 'A+'};
  print(grades[s2]);              // A+ -- found!
}
Tip: Use Object.hash(field1, field2, ...) for a fixed number of fields (up to 20). Use Object.hashAll(iterable) when hashing a collection. You can nest them as shown above -- Object.hash(name, Object.hashAll(list)).

The covariant Keyword

Sometimes you want your == override to accept a more specific type than Object. The covariant keyword tells Dart to relax the type check, allowing a parameter to be a subtype of the declared type.

Using covariant in Equality

class Animal {
  final String name;
  final int age;

  Animal(this.name, this.age);

  @override
  bool operator ==(covariant Animal other) {
    return name == other.name && age == other.age;
  }

  @override
  int get hashCode => Object.hash(name, age);
}

class Dog extends Animal {
  final String breed;

  Dog(super.name, super.age, this.breed);

  @override
  bool operator ==(covariant Dog other) {
    return super == other && breed == other.breed;
  }

  @override
  int get hashCode => Object.hash(name, age, breed);
}

void main() {
  final d1 = Dog('Rex', 5, 'Labrador');
  final d2 = Dog('Rex', 5, 'Labrador');
  final d3 = Dog('Rex', 5, 'Poodle');

  print(d1 == d2);  // true  -- same name, age, and breed
  print(d1 == d3);  // false -- different breed
}
Warning: Using covariant disables the type safety check. If you compare a Dog with an Animal that is not a Dog, you will get a runtime TypeError instead of a compile-time error. Use it only when you are confident about the types being compared.

Practical Example: Value Objects

Value objects are objects where identity is based on their data, not their reference. Common examples include addresses, date ranges, and coordinates. Here is a complete real-world value object pattern.

Address Value Object

class Address {
  final String street;
  final String city;
  final String state;
  final String zipCode;
  final String country;

  const Address({
    required this.street,
    required this.city,
    required this.state,
    required this.zipCode,
    required this.country,
  });

  @override
  bool operator ==(Object other) {
    if (identical(this, other)) return true;
    return other is Address &&
        other.street == street &&
        other.city == city &&
        other.state == state &&
        other.zipCode == zipCode &&
        other.country == country;
  }

  @override
  int get hashCode => Object.hash(street, city, state, zipCode, country);

  // copyWith for immutable updates
  Address copyWith({
    String? street,
    String? city,
    String? state,
    String? zipCode,
    String? country,
  }) {
    return Address(
      street: street ?? this.street,
      city: city ?? this.city,
      state: state ?? this.state,
      zipCode: zipCode ?? this.zipCode,
      country: country ?? this.country,
    );
  }

  @override
  String toString() => '$street, $city, $state $zipCode, $country';
}

class DateRange {
  final DateTime start;
  final DateTime end;

  DateRange(this.start, this.end) {
    if (end.isBefore(start)) {
      throw ArgumentError('End date must be after start date');
    }
  }

  bool get isActive {
    final now = DateTime.now();
    return now.isAfter(start) && now.isBefore(end);
  }

  Duration get duration => end.difference(start);

  bool overlaps(DateRange other) {
    return start.isBefore(other.end) && end.isAfter(other.start);
  }

  @override
  bool operator ==(Object other) {
    if (identical(this, other)) return true;
    return other is DateRange && other.start == start && other.end == end;
  }

  @override
  int get hashCode => Object.hash(start, end);

  @override
  String toString() => 'DateRange($start - $end)';
}

void main() {
  // Address equality
  final home1 = Address(
    street: '123 Main St',
    city: 'Springfield',
    state: 'IL',
    zipCode: '62701',
    country: 'US',
  );
  final home2 = Address(
    street: '123 Main St',
    city: 'Springfield',
    state: 'IL',
    zipCode: '62701',
    country: 'US',
  );

  print(home1 == home2);  // true -- same data = same address
  print({home1, home2}.length);  // 1 -- Set deduplicates them

  // DateRange usage
  final q1 = DateRange(DateTime(2024, 1, 1), DateTime(2024, 3, 31));
  final q2 = DateRange(DateTime(2024, 1, 1), DateTime(2024, 3, 31));
  print(q1 == q2);  // true
}

Entity Equality vs Value Equality

Not all objects should use value equality. Entities are objects identified by a unique ID (like a user or order), while value objects are identified by their data. Choosing the wrong approach causes subtle bugs.

Entity vs Value Object Equality

// ENTITY: Identified by unique ID
class User {
  final String id;
  String name;
  String email;

  User({required this.id, required this.name, required this.email});

  // Two users are equal if they have the same ID
  // (even if name/email changed)
  @override
  bool operator ==(Object other) {
    if (identical(this, other)) return true;
    return other is User && other.id == id;
  }

  @override
  int get hashCode => id.hashCode;
}

// VALUE OBJECT: Identified by all its data
class Color {
  final int r, g, b;
  const Color(this.r, this.g, this.b);

  // Two colors are equal if all components match
  @override
  bool operator ==(Object other) {
    if (identical(this, other)) return true;
    return other is Color && other.r == r && other.g == g && other.b == b;
  }

  @override
  int get hashCode => Object.hash(r, g, b);
}

void main() {
  // Entity: same ID = same entity (regardless of other fields)
  final u1 = User(id: 'abc', name: 'Alice', email: 'old@email.com');
  final u2 = User(id: 'abc', name: 'Alice Smith', email: 'new@email.com');
  print(u1 == u2);  // true -- same ID

  // Value Object: same data = same value
  final c1 = Color(255, 0, 0);
  final c2 = Color(255, 0, 0);
  print(c1 == c2);  // true -- same RGB
}
Decision Guide: Ask yourself: “If I change a field, is it still the same thing?” If a user changes their email, they are still the same user (entity). If you change a color’s red value, it is a different color (value object). Use ID-based equality for entities and field-based equality for value objects.
Summary: Proper equality and hash code implementation is essential for Dart objects that will be used in collections. Always override == and hashCode together. Use Object.hash() for convenient hash generation. Choose entity equality (by ID) or value equality (by fields) based on the nature of your object. The covariant keyword can simplify subclass equality but sacrifices type safety.