Generics, Mixins and Class Modifiers

Article · 13:00

Generics in Dart are not erased: a List of 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. With bounds, mixins and their on clause, and the sealed switch that cannot miss a case.

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; on states its requirement.
  • extends inherits, implements only promises the shape, with mixes in.
  • sealed gives the compiler the full list of subtypes, which makes switch exhaustive.
  • Exhaustiveness turns "I added a case and forgot a branch" into a compile error.