Generics, extensions and operator overloading

Write generic code with bounds, add behaviour to types you do not own, and implement == and hashCode so value types behave.

Generic classes and bounds

// an unbounded type parameter
class Box<T> {
  Box(this.value);
  T value;
  @override
  String toString() => 'Box($value)';
}

// a bound: T must be comparable to itself
class SortedList<T extends Comparable<T>> {
  final _items = <T>[];

  void add(T item) {
    _items.add(item);
    _items.sort();
  }

  T get max => _items.last;
  List<T> get items => List.unmodifiable(_items);
}

// a generic function
T firstOr<T>(List<T> items, T fallback) => items.isEmpty ? fallback : items.first;

Map<K, List<V>> groupBy<K, V>(Iterable<V> items, K Function(V) keyOf) {
  final out = <K, List<V>>{};
  for (final item in items) {
    out.putIfAbsent(keyOf(item), () => []).add(item);
  }
  return out;
}

void main() {
  final b = Box<int>(1);
  print(b);

  final s = SortedList<String>()..add('pear')..add('apple');
  print([s.max, s.items]);

  print(firstOr<int>([], 0));
  print(groupBy<String, int>([1, 2, 3, 4], (n) => n.isEven ? 'even' : 'odd'));

  // covariance: a List<Dog> is a List<Animal> for reading
  List<Object> animals = <String>['a'];
  print(animals.length);
  // animals.add(1);   // throws at run time: the runtime type is List<String>
}
  • Dart generics are reified: the type argument exists at run time, so List<int> and List<String> are different types and a cast is checked.
  • A generic collection is covariant for reads and checked on write. Adding the wrong type throws a TypeError at run time, not a compile error.
  • Use Iterable<T> for a parameter you only read and List<T> when you need indexing or mutation. Accepting the widest type you can is what makes a function reusable.
  • dynamic disables type checking and is a performance cost. Prefer Object? plus a cast, or a generic parameter.

Extension methods

// add members to a type you do not own
extension NumberFormatting on num {
  String get asCurrency => '\${toStringAsFixed(2)}';
  bool get isWhole => this == truncate();
}

extension StringExtras on String {
  String get capitalized =>
      isEmpty ? this : '${this[0].toUpperCase()}${substring(1)}';

  bool get isEmail => RegExp(r'^[^@\s]+@[^@\s]+\.[^@\s]+$').hasMatch(this);

  // an extension can add an operator
  String operator %(String other) => '$this/$other';
}

extension IterableExtras<T> on Iterable<T> {
  // a generic extension method with a type parameter of its own
  Map<K, List<T>> grouped<K>(K Function(T) keyOf) {
    final out = <K, List<T>>{};
    for (final item in this) {
      out.putIfAbsent(keyOf(item), () => []).add(item);
    }
    return out;
  }

  T? get firstOrNull => isEmpty ? null : first;
}

void main() {
  print(3.5.asCurrency);
  print(4.isWhole);
  print('ada'.capitalized);
  print('[email protected]'.isEmail);
  print('a' % 'b');
  print([1, 2, 3, 4].grouped((n) => n.isEven ? 'even' : 'odd'));
  print([].firstOrNull);
}
RuleDetailConsequence
ResolutionStatic, based on the static typeAn extension on num does not apply to a variable typed Object
ScopeMust be imported and in scopeExtensions are not global
ConflictA real member always winsYou cannot shadow a class member
AmbiguityTwo applicable extensions is an errorImport the one you want, or hide the other
Private membersNot accessibleExtensions are outside the class
Null safetyAn extension on T? can handle nullUseful for a null-safe helper

An extension cannot add state and cannot be dispatched dynamically: the call is resolved from the static type. That is a feature, since it means no vtable lookup and no surprise at run time, but it also means an extension on a supertype is not found through a more specific static type unless it is declared on that type.

Operators and the equality contract

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

  // operators are methods with a fixed set of names
  Vector operator +(Vector o) => Vector(x + o.x, y + o.y);
  Vector operator -(Vector o) => Vector(x - o.x, y - o.y);
  Vector operator *(double s) => Vector(x * s, y * s);
  Vector operator -() => Vector(-x, -y);          // unary minus

  // index and call operators
  double operator [](int i) => i == 0 ? x : y;
  double call() => x * y;

  // equality and hashing must agree
  @override
  bool operator ==(Object other) =>
      other is Vector && other.x == x && other.y == y;

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

class Counted {
  int _n = 0;
  // comparison operators must return bool, unlike C++
  bool operator <(Counted o) => _n < o._n;
  void operator +=(int n) => _n += n;
  int get value => _n;
}

enum Priority {
  low(1), medium(5), high(9);
  const Priority(this.weight);
  final int weight;

  // a member on an enum, and the built-in comparison operators
  bool operator >(Priority other) => weight > other.weight;
}

void main() {
  const a = Vector(1, 2), b = Vector(3, 4);
  print(a + b);
  print(a * 2);
  print(-a);
  print(a[1]);
  print(a == const Vector(1, 2));

  final c = Counted()..+= 5;
  print(c.value);

  print(Priority.high > Priority.low);
  print(Priority.values.map((p) => p.name).toList());
}
⚠️
Overload == only for a type whose value is its identity. A mutable object with value equality is a hazard in a Set or as a Map key: mutating a field after insertion changes the hash and makes the entry unreachable.

FAQ

Extension or a util function?
An extension when the operation reads naturally as a property of the value and belongs to the domain. A plain function when it is a general algorithm, or when it would need to be callable on a type that might later define the same member.
Can I add an extension to dynamic?
No. Extensions resolve on the static type, and dynamic defers everything to run time, so an extension is never found. Type the variable properly.

Collections and functional iteration Records, patterns and switch expressions

Last refreshed 2026-09-18.