Generics, Mixins and Class Modifiers
Generics in Dart are not erased. List<int> knows it is a list of ints at
runtime, so a cast that would be a silent lie in some languages fails here where
you can see it.
class Box<T> {
final T value;
const Box(this.value);
Box<R> map<R>(R Function(T) f) => Box<R>(f(value));
@override
String toString() => 'Box<$T>($value)';
}
void main() {
const numbers = Box<int>(42);
print(numbers);
print(numbers.map((n) => n.toString()));
// The type argument is inferred from the argument.
print(Box('inferred'));
// And it is there at runtime, unlike in languages that erase it: this is an
// Object as far as the compiler knows, so the checks are real questions.
final Object list = <int>[1, 2, 3];
print(list is List<int>);
print(list is List<String>);
}
Bounds
T extends something restricts what can be substituted, and in exchange lets
the body use that something's members.
// Without the bound, a < b would not compile: Object has no <.
T largest<T extends Comparable<T>>(List<T> values) {
var best = values.first;
for (final value in values.skip(1)) {
if (value.compareTo(best) > 0) best = value;
}
return best;
}
void main() {
print(largest([3, 9, 4]));
print(largest(['pear', 'apple', 'quince']));
// print(largest([Object(), Object()])); // Object is not Comparable
}
Mixins: behaviour without a place in the hierarchy
A mixin is a set of members that can be added to a class that already has a
superclass. on restricts what it can be mixed into, and in exchange lets the
mixin call that type's members.
mixin Loggable {
// The mixin can declare what it needs from the host class.
String get label;
final List<String> _log = [];
void log(String message) => _log.add('[$label] $message');
List<String> get history => List.unmodifiable(_log);
}
mixin Timestamped on Loggable {
// `on Loggable` means this mixin may call Loggable's members.
void logAt(String message, DateTime when) =>
log('${when.toIso8601String()} $message');
}
class Job with Loggable, Timestamped {
@override
final String label;
Job(this.label);
}
void main() {
final job = Job('nightly')
..log('started')
..logAt('halfway', DateTime.utc(2026, 1, 1))
..log('finished');
job.history.forEach(print);
}
extends, implements, with - three different promises
class Engine {
String start() => 'vroom';
}
// implements takes the INTERFACE only: nothing is inherited, everything must
// be written again. Any class can be used as an interface.
class SilentEngine implements Engine {
@override
String start() => '...';
}
// extends takes the implementation and the interface, and there can be only one.
class TurboEngine extends Engine {
@override
String start() => '${super.start()} whoosh';
}
void main() {
final engines = <Engine>[Engine(), SilentEngine(), TurboEngine()];
for (final engine in engines) {
print('${engine.runtimeType}: ${engine.start()}');
}
}
Class modifiers, and the exhaustive switch they buy
Dart 3 added modifiers that state what a class permits. The most useful is
sealed: subclasses must live in the same library, so the compiler knows the
complete list - and a switch over them is checked for exhaustiveness.
sealed class Shape {
const Shape();
}
class Square extends Shape {
final double side;
const Square(this.side);
}
class Circle extends Shape {
final double radius;
const Circle(this.radius);
}
// No default branch, and no way to forget one: adding a third subclass makes
// this function stop compiling until it is handled.
double area(Shape shape) => switch (shape) {
Square(:final side) => side * side,
Circle(:final radius) => 3.141592653589793 * radius * radius,
};
void main() {
const shapes = <Shape>[Square(2), Circle(1)];
for (final shape in shapes) {
print(area(shape).toStringAsFixed(3));
}
}
The other modifiers say the same kind of thing in different directions:
final forbids subclassing outside the library, base requires subclasses to
be base (so the implementation is always inherited, never merely implemented),
and interface allows implements but forbids extends outside the library.
They exist so a library author can promise something about how a type will be
used, and have the compiler hold everyone to it.
What to take away
- Generics are reified:
List<int>knows its type argument at runtime. - A bound (
T extends Comparable<T>) is what lets the body use the members. - A mixin adds behaviour to a class that already has a superclass;
onstates its requirement. extendsinherits,implementsonly promises the shape,withmixes in.sealedgives the compiler the full list of subtypes, which makesswitchexhaustive.- Exhaustiveness turns "I added a case and forgot a branch" into a compile error.