Blog
17

Equality in Dart: What `==` Actually Does

Dart Equality Explained: ==, hashCode, identical(), Equatable, and Freezed

March 27, 2026

You add an object to a Set. You check if the Set contains it. It doesn't.

dart
final users = <User>{};
final alice = User(name: 'Alice', age: 30);
users.add(alice);

final alsoAlice = User(name: 'Alice', age: 30);
print(users.contains(alsoAlice)); // false

Same name. Same age. The Set says it's not there.

Or you emit a new state in BLoC. The data changed. The UI doesn't update. No error, no crash — BLoC compared the old state to the new state, decided they were equal, and swallowed the emission silently.

Both of these are the same bug. Both come from not understanding what == does in Dart by default — and what you need to do to make it work the way you expect.

Two Questions That Look Like One

When you write a == b, you're asking: "Are these the same?"

But "the same" has two completely different meanings:

  1. Same object. Literally the same instance in memory. One object, two variables pointing to it.
  2. Same value. Two separate objects that contain identical data.
dart
final a = User(name: 'Alice', age: 30);
final b = a;  // same object — b points to where a points
final c = User(name: 'Alice', age: 30);  // different object, same data

a and b are the same object. Change a field through a, and b sees the change — because there's only one object.

a and c are different objects with the same data. They were created separately. They occupy different locations in memory. They just happen to hold identical values.

The question is: when you write a == c, which answer do you want?

Dart's Default: Reference Equality

By default, == in Dart checks whether two variables point to the same object. This is called reference equality — it compares memory addresses, not data.

dart
class User {
  final String name;
  final int age;
  User({required this.name, required this.age});
}

final a = User(name: 'Alice', age: 30);
final b = User(name: 'Alice', age: 30);

print(a == b);        // false — different objects
print(identical(a, b)); // false — same thing, different syntax

identical(a, b) is Dart's explicit reference equality check. By default, == does the same thing. Two objects created with two separate constructor calls are never ==, even if every field matches.

This is a reasonable default. For many types — database connections, HTTP clients, stream controllers — two instances with the same configuration are genuinely different things. You wouldn't want databaseA == databaseB to return true just because they connect to the same URL.

But for data — a price, a coordinate, a search query, a BLoC state — reference equality is almost never what you want. Two SearchState objects with the same query and results should be equal. Two Money objects with the same amount and currency should be equal. If they're not, things break in subtle ways.

Overriding `==`: Value Equality

To make == compare data instead of references, you override the == operator:

dart
class Money {
  final double amount;
  final String currency;

  Money({required this.amount, required this.currency});

  @override
  bool operator ==(Object other) =>
      identical(this, other) ||
      other is Money &&
          runtimeType == other.runtimeType &&
          amount == other.amount &&
          currency == other.currency;

  @override
  int get hashCode => amount.hashCode ^ currency.hashCode;
}

Now:

dart
final a = Money(amount: 100, currency: 'EUR');
final b = Money(amount: 100, currency: 'EUR');

print(a == b); // true — same amount, same currency

The == override checks each field. The identical(this, other) shortcut at the top is an optimization — if it's literally the same object, skip the field comparisons.

But there's that hashCode override sitting underneath. It looks like boilerplate. It's not. It's the thing that makes your objects work in Set, Map, and every hash-based collection in Dart.

The hashCode Contract

hashCode is an integer that represents the object's data. Dart's hash-based collections — Set, Map, LinkedHashSet, HashMap — use it to organize and find objects efficiently.

Here's the contract, and it's non-negotiable:

If two objects are equal (`a == b` is `true`), they must have the same `hashCode`.

The reverse is not required — two objects with the same hashCode don't have to be equal. (Hash collisions are normal and handled gracefully.) But equal objects with different hash codes break everything.

Why This Matters: How Sets and Maps Work

A Set is not a list that rejects duplicates. Internally, it's a hash table — an array of buckets, where each bucket holds objects that share the same hash code.

When you call set.add(object):

  1. Dart computes object.hashCode
  2. Uses that hash to pick a bucket (via modulo arithmetic)
  3. Checks the bucket for an existing object where existing == object
  4. If found: skip (it's a duplicate). If not: add it to the bucket.

When you call set.contains(object):

  1. Dart computes object.hashCode
  2. Picks the bucket
  3. Checks the bucket for an object where existing == object
  4. Returns true if found, false if not.

Notice: the hash code determines which bucket to look in. If two equal objects produce different hash codes, they end up in different buckets. contains looks in the wrong bucket and says "not found" — even though the object is right there, in a different bucket, waiting to be found.

javascript
Set internals (simplified):

Bucket 0: [ ]
Bucket 1: [ ]
Bucket 2: [ Money(100, EUR)  ← added here (hashCode % buckets = 2) ]
Bucket 3: [ ]
Bucket 4: [ ]

contains(Money(100, EUR)) → hashCode % buckets = ???
  If hashCode is consistent: → bucket 2 → found ✓
  If hashCode differs:       → bucket 4 → not found ✗ (object is in bucket 2!)

This is why the contract exists. Break it, and objects vanish from collections.

The Practical Disaster

dart
class User {
  String name;  // mutable!
  int age;

  User({required this.name, required this.age});

  @override
  bool operator ==(Object other) =>
      identical(this, other) ||
      other is User && name == other.name && age == other.age;

  @override
  int get hashCode => name.hashCode ^ age.hashCode;
}

final users = <User>{};
final alice = User(name: 'Alice', age: 30);
users.add(alice);

print(users.contains(alice)); // true — so far so good

alice.name = 'Alicia';  // mutate the object

print(users.contains(alice)); // false — it's IN the set, but the set can't find it

The object is in the Set. It was never removed. But when we changed name, the hashCode changed. The Set looks in the bucket for the new hash code — and finds nothing. The object is stranded in its old bucket, unreachable.

This is why value objects should be immutable. If an object's fields participate in == and hashCode, mutating those fields after the object has been stored in a hash-based collection corrupts the collection silently. No exception. No warning. The object simply becomes invisible.

If you've read the value objects article, this is the mechanical reason behind the "replace, don't mutate" rule. Immutability isn't a philosophical preference — it's the thing that keeps your objects findable in collections and comparable in state management.

The Boilerplate Problem

Correct == and hashCode require listing every field in two places:

dart
class SearchState {
  final String query;
  final List<Lead> results;
  final bool isLoading;
  final int currentPage;

  SearchState({
    required this.query,
    required this.results,
    this.isLoading = false,
    this.currentPage = 1,
  });

  @override
  bool operator ==(Object other) =>
      identical(this, other) ||
      other is SearchState &&
          runtimeType == other.runtimeType &&
          query == other.query &&
          results == other.results &&
          isLoading == other.isLoading &&
          currentPage == other.currentPage;

  @override
  int get hashCode =>
      query.hashCode ^
      results.hashCode ^
      isLoading.hashCode ^
      currentPage.hashCode;
}

Four fields. Twenty lines of equality code. And every time you add a field, you update both == and hashCode. Forget one, and you've introduced a silent bug — two objects with different data that your app considers equal, or two equal objects with different hash codes that disappear from collections.

This is the problem that Equatable and Freezed both solve. They just solve it differently.

Equatable: The Props List

Equatable overrides == and hashCode for you, based on a list of properties you declare:

dart
class SearchState extends Equatable {
  final String query;
  final List<Lead> results;
  final bool isLoading;
  final int currentPage;

  const SearchState({
    required this.query,
    required this.results,
    this.isLoading = false,
    this.currentPage = 1,
  });

  @override
  List<Object?> get props => [query, results, isLoading, currentPage];
}

What Equatable does under the hood is straightforward. The == override iterates through props and compares each element. The hashCode override combines the hash codes of all elements in props. You don't see this code — it lives in the Equatable base class — but it's doing exactly what the manual override above does, driven by the props list instead of hardcoded fields.

dart
// Simplified version of what Equatable generates:
@override
bool operator ==(Object other) =>
    identical(this, other) ||
    other is SearchState &&
        runtimeType == other.runtimeType &&
        _listEquals(props, other.props);

@override
int get hashCode => _combineHashCodes(props);

The advantage: less boilerplate. Instead of writing == and hashCode by hand, you maintain a single props list.

The risk: props is a separate list from your fields. They can go out of sync. Add a field to the class, forget to add it to props, and equality silently ignores that field. The Equatable trap article covers this specific bug in depth — it's the most common cause of BLoC states that emit but don't trigger UI rebuilds.

How Equatable Compares Collections

One detail worth knowing: Equatable uses deep equality for collections. If your props list contains a List<Lead>, Equatable doesn't just compare the list references — it compares the contents, element by element.

dart
final a = SearchState(query: 'flutter', results: [lead1, lead2]);
final b = SearchState(query: 'flutter', results: [lead1, lead2]);

print(a == b); // true — Equatable compares list contents, not references

This is different from Dart's default == for lists, which compares references:

dart
final listA = [1, 2, 3];
final listB = [1, 2, 3];
print(listA == listB); // false — different list instances

Equatable wraps its comparison in DeepCollectionEquality from the collection package. This means two Equatable objects with separate but content-identical lists are considered equal — which is usually what you want for state management.

---

Freezed: Equality Without the Props List

Freezed takes a different approach. Instead of asking you to declare which fields participate in equality, it generates == and hashCode from the factory constructor's parameters — automatically, always, for every field.

dart
@freezed
class SearchState with _$SearchState {
  const factory SearchState({
    required String query,
    required List<Lead> results,
    @Default(false) bool isLoading,
    @Default(1) int currentPage,
  }) = _SearchState;
}

The generated == compares query, results, isLoading, and currentPage. There is no props list. The generated code derives directly from the factory constructor's parameters. Add a field, re-run build_runner, and equality updates automatically. It cannot go out of sync because there's only one source of truth.

Like Equatable, Freezed uses DeepCollectionEquality for collections. Two SearchState objects with separate but content-identical results lists are equal.

Unlike Equatable, Freezed also generates copyWith, toString, and enforces immutability (all fields in a @freezed class are effectively final). If you've read the Freezed guide, you know the full picture. For the purpose of this article, the key point is: Freezed solves the equality problem by removing the possibility of the props desync entirely.

The tradeoff is code generation. Equatable is a runtime solution — no build_runner, no generated files. Freezed requires running dart run build_runner build every time you change a class. For small projects or classes that rarely change, Equatable's simplicity wins. For state classes that evolve frequently, Freezed's guarantee wins.

Manual `==`: When You Need Identity Equality

There's a third option: override == yourself, comparing on a single field. This is the right approach for entities — objects that have a persistent identity independent of their data.

dart
class User {
  final String id;
  final String name;
  final String email;

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

  @override
  bool operator ==(Object other) =>
      identical(this, other) ||
      other is User && id == other.id;

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

Two User objects with the same id are the same user — even if the name or email differs (maybe one is a stale copy). This is identity equality: the id field is the identity, and everything else is data that can change.

This is the opposite of value equality. With value equality, all fields matter. With identity equality, only the identifier matters. If you've read our natural value object approach, this is the mechanical distinction behind the conceptual one.

Neither Equatable nor Freezed naturally handles identity equality. Equatable compares all fields in props — you'd have to put only id in props, which works but feels like misusing the tool. Freezed compares all fields, period — you can't tell it to ignore name and email. For entity classes, a manual == override with just the id is the cleanest approach.

identical() vs ==

Dart has two ways to compare:

  • == — customizable. By default checks references, but can be overridden to check values.
  • identical(a, b) — always checks references. Cannot be overridden. Asks: "Is this literally the same object in memory?"

When does identical() matter?

Compile-time constants. When you write const in Dart, the runtime canonicalizes the value — one instance for each unique const expression. identical() is how you verify canonicalization:

dart
const a = Money(amount: 100, currency: 'EUR');
const b = Money(amount: 100, currency: 'EUR');

print(a == b);           // true — value equality
print(identical(a, b));  // true — same object (canonicalized)
dart
final a = Money(amount: 100, currency: 'EUR');
final b = Money(amount: 100, currency: 'EUR');

print(a == b);           // true — value equality (if == is overridden)
print(identical(a, b));  // false — different objects

If you've read the const vs final article, you know why this matters for Flutter: the framework uses identical() as a fast path to skip widget rebuilds. A const widget is always identical() to itself, so Flutter skips the subtree instantly — no == comparison needed.

BLoC's emit check. BLoC checks state == _state before emitting. If your state class has correct ==, this works. But if your state is a const Freezed instance, the == check short-circuits to identical() — same object, instant comparison, zero field-by-field work. This is why emit(const AuthState.loading()) is faster than emit(AuthState.loading()) — the const version produces a single canonical object that == recognizes instantly.

When to Use What

| Scenario | Approach | Why | |----------|----------|-----| | BLoC state classes that change often | Freezed | Equality always correct, no props desync, copyWith included | | Simple data classes, few fields, stable | Equatable | No code generation, lightweight | | Entities (identity = single ID field) | Manual `==` | Neither Equatable nor Freezed handles identity equality naturally | | One-off comparison, throwaway types | Don't override | Reference equality is fine for transient objects |

The decision isn't about which library is "better." It's about what kind of equality the object needs, and how much the equality definition will change over time.

For state classes — where fields are added, removed, and renamed as the feature evolves — Freezed's structural guarantee is worth the code generation cost. The props desync bug described in the Equatable trap article is not a rare edge case; it's the most common silent bug in BLoC-based Flutter apps. Removing the possibility of that bug is worth a build step.

For stable data classes — configuration objects, API error types, simple DTOs with three fields that haven't changed in six months — Equatable is simpler and works perfectly.

For entities — users, orders, products, anything with a database ID — write == by hand. Three lines. One field. Correct forever.

Equality in Dart is not complicated. It's just invisible by default. The == operator does reference comparison unless you tell it otherwise. hashCode must agree with == or collections break. Immutability keeps hashCode stable.

These rules don't come from Equatable or Freezed or BLoC. They come from Dart itself — from how the language implements object comparison and how hash tables organize data. Equatable and Freezed are tools that make it easier to follow the rules correctly. Understanding the rules makes the tools make sense.

Related Topics

dart equality operatordart hashcode explaineddart identical vs equalsdart equatable how it worksdart freezed equalitydart set contains not workingdart map key not founddart value equalityflutter bloc equalitydart object comparison

Ready to build your app?

Flutter apps built on Clean Architecture — documented, tested, and yours to own. See which plan fits your project.