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>andList<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
TypeErrorat run time, not a compile error. - Use
Iterable<T>for a parameter you only read andList<T>when you need indexing or mutation. Accepting the widest type you can is what makes a function reusable. dynamicdisables type checking and is a performance cost. PreferObject?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);
}| Rule | Detail | Consequence |
|---|---|---|
| Resolution | Static, based on the static type | An extension on num does not apply to a variable typed Object |
| Scope | Must be imported and in scope | Extensions are not global |
| Conflict | A real member always wins | You cannot shadow a class member |
| Ambiguity | Two applicable extensions is an error | Import the one you want, or hide the other |
| Private members | Not accessible | Extensions are outside the class |
| Null safety | An extension on T? can handle null | Useful 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.Related
Collections and functional iteration Records, patterns and switch expressions
Last refreshed 2026-09-18.