← Back to blog
Object-Oriented Dart · Part 4 of 11
June 27, 202613 min read

Methods, Operators & Cascades in Dart

DartOOPFlutter

Methods, Operators & Cascades

This is Part 4 of the Object-Oriented Dart series. We've covered classes, constructors, and encapsulation. Now we focus on behavior — the methods that make objects do things — and on some of Dart's most satisfying ergonomic features: operator overloading, value equality, and cascades.

By the end you'll be able to write a class whose objects you can add together with +, compare with ==, and configure in one fluent chain.


Instance methods, revisited

You already know an instance method is a function defined inside a class that operates on a specific object via its fields and this. Let's level up the details.

import 'dart:math';

class Point {
  final double x, y;
  const Point(this.x, this.y);

  // A method that uses both `this` and another object.
  double distanceTo(Point other) {
    final dx = x - other.x;
    final dy = y - other.y;
    return sqrt(dx * dx + dy * dy);
  }
}

void main() {
  const a = Point(0, 0);
  const b = Point(3, 4);
  print(a.distanceTo(b)); // 5.0
}

Methods can take named and optional parameters exactly like top-level functions, and single-expression methods can use the arrow shorthand:

class Greeter {
  final String name;
  Greeter(this.name);

  // Arrow form: body is a single expression.
  String hello() => 'Hi, $name!';

  // Optional named parameter with a default.
  String greet({String greeting = 'Hello'}) => '$greeting, $name!';
}

There's nothing exotic here — the point is that methods are just functions that get a free this. The interesting part is the special methods Dart lets you define.


Operator overloading

In Dart, operators like +, -, *, ==, and [] are really just methods with funny names. That means you can define what they mean for your own types. This is called operator overloading, and you do it with the operator keyword:

class Vector {
  final double x, y;
  const Vector(this.x, this.y);

  Vector operator +(Vector other) => Vector(x + other.x, y + other.y);
  Vector operator -(Vector other) => Vector(x - other.x, y - other.y);
  Vector operator *(double scale) => Vector(x * scale, y * scale);

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

void main() {
  const a = Vector(1, 2);
  const b = Vector(3, 4);

  print(a + b);   // Vector(4.0, 6.0)
  print(b - a);   // Vector(2.0, 2.0)
  print(a * 10);  // Vector(10.0, 20.0)
}

Reading a + b is so much nicer than a.add(b). The operators you can overload include + - * / ~/ % < > <= >= == & | ^ ~ << >> >>> [] []=, and the unary minus (written as operator -() with no parameter).

This shines for math-like types (vectors, money, matrices, complex numbers, durations). Use it sparingly elsewhere — overloading + to mean something non-obvious just confuses readers. The rule: overload an operator only when its meaning is obvious for your type.

Indexing with [] and []=

You can even make your object behave like a collection:

class Grid {
  final List<List<int>> _cells;
  Grid(int rows, int cols)
      : _cells = List.generate(rows, (_) => List.filled(cols, 0));

  int operator [](int row) => _cells[row][0]; // simplified read
  void operator []=(int row, int value) => _cells[row][0] = value;
}

Value equality: == and hashCode

This is one of the most important — and most commonly botched — topics in Dart OOP, so let's be careful.

By default, == checks identity: are these two references the same object in memory? For two separately-constructed objects with identical data, that's false:

class Money {
  final int cents;
  Money(this.cents);
}

void main() {
  var a = Money(500);
  var b = Money(500);
  print(a == b); // false — different objects, even though values match!
}

Often that's not what you want. Two Money(500) objects represent the same amount; they should be equal. To get value equality, override == — and whenever you override ==, you must also override hashCode to stay consistent:

class Money {
  final int cents;
  const Money(this.cents);

  @override
  bool operator ==(Object other) =>
      other is Money && other.cents == cents;

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

void main() {
  const a = Money(500);
  const b = Money(500);
  print(a == b); // true ✅
}

Why hashCode must travel with ==

Hash-based collections (Set, Map) use hashCode to find objects in buckets first, then == to confirm a match. The contract is: if a == b, then a.hashCode == b.hashCode. Break it and your objects vanish into the wrong buckets:

void main() {
  final seen = <Money>{};
  seen.add(const Money(500));
  print(seen.contains(const Money(500)));
  // With both overridden: true.
  // With only == overridden (default hashCode): could be false — chaos.
}

A few notes:

  • The parameter type must be Object (not Money) to correctly override the inherited ==. That's why we type-check with other is Money inside.
  • For multiple fields, combine hashes with Object.hash(field1, field2, ...):
@override
int get hashCode => Object.hash(street, city, zip);
  • Writing correct ==/hashCode by hand is tedious and error-prone. In real projects, most people generate them with packages like equatable or freezed, or use Dart records for simple value bundles (records have built-in value equality). But you should understand the manual version first — that's what those tools generate for you.

Mental model: identity (identical(a, b)) asks "same object?"; equality (a == b) asks "same value?" — and you decide what "same value" means by overriding ==.


The cascade operator ..

Often you want to call several methods or set several properties on the same object in a row. Without help, you repeat the variable name:

var p = StringBuffer();
p.write('Hello');
p.write(', ');
p.write('world');
p.write('!');

The cascade operator .. lets you chain operations on one object without repeating it. Each .. operates on the original object and (crucially) returns that object, not the result of the call:

var greeting = (StringBuffer()
      ..write('Hello')
      ..write(', ')
      ..write('world')
      ..write('!'))
    .toString();

print(greeting); // Hello, world!

It works for setting properties too — this is extremely common in Flutter and config-style code:

var paint = Paint()
  ..color = Colors.blue
  ..strokeWidth = 4
  ..style = PaintingStyle.stroke;

That's one Paint object, configured fluently. Compare it to four separate paint.x = ... lines — the cascade keeps related setup visually grouped and avoids a throwaway variable.

?.. — the null-aware cascade

If the target might be null, use ?.. so the whole chain is skipped on null instead of crashing:

button?..text = 'OK'..onPressed = handleTap;

The key insight about ..: a normal . returns whatever the method returns; a cascade .. discards that and returns the receiver instead. That's why you can keep chaining on the same object.


Callable objects

A lesser-known trick: give a class a method literally named call, and its instances can be invoked like functions:

class Adder {
  final int by;
  Adder(this.by);

  int call(int value) => value + by; // makes instances callable
}

void main() {
  final addFive = Adder(5);
  print(addFive(10)); // 15 — calling the object like a function!
}

addFive(10) quietly becomes addFive.call(10). This is handy when you want something that behaves like a function but also carries state or configuration — a validator, a formatter, a strategy object. It's niche, but when it fits, it's elegant.


Putting it together

A small Money type with arithmetic, value equality, and a clean string form:

class Money implements Comparable<Money> {
  final int cents;
  const Money(this.cents);

  Money operator +(Money other) => Money(cents + other.cents);
  Money operator -(Money other) => Money(cents - other.cents);
  bool operator <(Money other) => cents < other.cents;

  @override
  bool operator ==(Object other) => other is Money && other.cents == cents;

  @override
  int get hashCode => cents.hashCode;

  @override
  int compareTo(Money other) => cents.compareTo(other.cents);

  @override
  String toString() => '\$${(cents / 100).toStringAsFixed(2)}';
}

void main() {
  const lunch = Money(1250);
  const coffee = Money(450);

  print(lunch + coffee);          // $17.00
  print(lunch == Money(1250));    // true
  print(coffee < lunch);          // true

  final prices = [lunch, coffee]..sort(); // cascade + Comparable
  print(prices);                  // [$4.50, $12.50]
}

That ..sort() works because we implemented compareTo, and the cascade lets us sort and keep the list in one expression. Several Part-4 ideas working together.


Practice Challenges

Challenge 1 — Overload +. Create a Duration2-style class holding minutes, and overload + so a + b adds their minutes.

Show solution
class Mins {
  final int minutes;
  const Mins(this.minutes);

  Mins operator +(Mins other) => Mins(minutes + other.minutes);

  @override
  String toString() => '$minutes min';
}

void main() {
  print(const Mins(30) + const Mins(45)); // 90 min
}

operator + is just a method named +. It takes the right-hand operand and returns a new Mins.

Challenge 2 — Value equality. Make a Coordinate class with lat/lng so that two coordinates with the same values are == and behave correctly in a Set.

Show solution
class Coordinate {
  final double lat, lng;
  const Coordinate(this.lat, this.lng);

  @override
  bool operator ==(Object other) =>
      other is Coordinate && other.lat == lat && other.lng == lng;

  @override
  int get hashCode => Object.hash(lat, lng);
}

void main() {
  final places = <Coordinate>{
    const Coordinate(1, 2),
    const Coordinate(1, 2), // duplicate — collapses
  };
  print(places.length); // 1
}

Both == and hashCode are overridden together, so the Set correctly treats the two identical coordinates as one. Object.hash combines the two fields safely.

Challenge 3 — Spot the bug. This class overrides == but not hashCode. Describe what goes wrong, then fix it.

class Tag {
  final String label;
  Tag(this.label);

  @override
  bool operator ==(Object other) => other is Tag && other.label == label;
}
Show solution

What goes wrong: hashCode still uses the default identity hash, so two equal Tags get different hash codes. In a Set or Map, they land in different buckets, and contains/lookup can fail to find an "equal" element — violating the ==/hashCode contract.

Fix:

class Tag {
  final String label;
  Tag(this.label);

  @override
  bool operator ==(Object other) => other is Tag && other.label == label;

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

Rule: override == and hashCode together, always.

Challenge 4 — Cascade configuration. Using a cascade, create and configure a StringBuffer that ends up containing "[1, 2, 3]", then print it.

Show solution
void main() {
  final out = (StringBuffer()
        ..write('[')
        ..writeAll([1, 2, 3], ', ')
        ..write(']'))
      .toString();

  print(out); // [1, 2, 3]
}

Each .. operates on the same StringBuffer and returns it, so the writes chain. We wrap the whole thing in parentheses to then call .toString() on the buffer.

Challenge 5 — Callable object. Write a Multiplier whose instances can be called like functions: Multiplier(3)(10) returns 30.

Show solution
class Multiplier {
  final int factor;
  Multiplier(this.factor);

  int call(int value) => value * factor;
}

void main() {
  final triple = Multiplier(3);
  print(triple(10)); // 30
  print([1, 2, 3].map(triple).toList()); // [3, 6, 9]
}

Defining call makes instances invocable. Bonus: because triple acts like a function, you can even pass it straight to map.

Challenge 6 — Comparable + sort. Make a Version class (major, minor) that implements Comparable<Version> so a list of versions sorts correctly with ..sort().

Show solution
class Version implements Comparable<Version> {
  final int major, minor;
  const Version(this.major, this.minor);

  @override
  int compareTo(Version other) {
    if (major != other.major) return major.compareTo(other.major);
    return minor.compareTo(other.minor);
  }

  @override
  String toString() => '$major.$minor';
}

void main() {
  final versions = [
    const Version(1, 2),
    const Version(1, 0),
    const Version(2, 0),
  ]..sort();
  print(versions); // [1.0, 1.2, 2.0]
}

compareTo returns negative/zero/positive to mean less/equal/greater. We compare major first, falling back to minor. ..sort() then orders the list in place using that logic.


Questions to test yourself

Q1 (basic). What does the operator keyword let you do?

Show answer

It lets you define what built-in operators (+, -, ==, [], etc.) mean for your own type — operator overloading. Vector operator +(Vector o) => ... makes a + b work for your Vector class.

Q2 (basic). By default, what does == compare for two objects of your custom class?

Show answer

Identity — whether the two references point to the same object in memory. Two separately-constructed objects with identical field values are not == unless you override == to compare values.

Q3 (intermediate). Why must the parameter of an overridden == be typed Object rather than your own class?

Show answer

Because you're overriding Object's ==, whose signature is bool operator ==(Object other). To be a valid override, the parameter type must match (or be a supertype). Typing it as your class would change the signature and not actually override the inherited operator — so you accept Object and narrow it with other is MyType inside.

Q4 (intermediate). What exactly does a cascade (..) return, and why does that let you chain?

Show answer

A cascade discards the value the called member returns and instead evaluates to the original receiver (the object you're cascading on). Because every .. yields the same object back, you can keep applying more .. operations to it in a chain — unlike ., which returns the member's result.

Q5 (advanced). What's the ==/hashCode contract, and what concretely breaks if you override == but leave the default hashCode?

Show answer

The contract: if a == b then a.hashCode == b.hashCode (equal objects must share a hash). Hash-based collections (Set, Map) locate objects by hashCode bucket first, then confirm with ==. If you override == but keep the identity-based default hashCode, two "equal" objects get different hashes, land in different buckets, and lookups/contains can fail to find them — so dedup, Set membership, and Map keys silently misbehave.

Q6 (advanced). How does a "callable object" (call method) differ from just storing a closure in a field, and when might you prefer it?

Show answer

A callable object is a full object — it can have multiple methods, named constructors, mutable/immutable state, implement interfaces, and be subtyped — while also being invocable via call. A stored closure is just a function value with captured variables. Prefer a callable object when you want function-like usage plus the richness of a class: e.g., a configurable strategy/validator that also exposes other methods or carries explicit, inspectable state.


Wrapping up

You can now design objects with a rich, ergonomic surface:

  • Methods are functions with a free this; use arrow syntax for one-liners.
  • Operator overloading (operator +, [], …) makes math-like types read naturally — use it only when the meaning is obvious.
  • Value equality means overriding == and hashCode together (param typed Object, combine fields with Object.hash); tools like equatable/freezed and records automate it.
  • Cascades (.., ?..) configure one object fluently by returning the receiver each time.
  • Callable objects (a call method) let instances be invoked like functions.

In Part 5 we reach the second and third pillars together: inheritance and polymorphismextends, super, @override, and how one call can mean different things to different objects.