Skip to content

06 · Generics

You've been using generics since Level 1 every time you wrote List<int> or Map<String, dynamic>List and Map are themselves generic classes. This module covers writing your own generic classes and functions: code that works across many types while still giving you full compile-time type checking, instead of falling back to dynamic and losing that safety.

A generic class

Box<T> declares a type parameter T that's filled in when the class is used — Box<int>, Box<String>, Box<Score>, all from one class definition.

class Box<T> {
  T value;
  Box(this.value);

  void replace(T newValue) {
    value = newValue;
  }

  @override
  String toString() => 'Box($value)';
}

void main() {
  final intBox = Box<int>(42);
  final stringBox = Box<String>('hello');
  print(intBox);     // Box(42)
  print(stringBox);  // Box(hello)

  intBox.replace(100);
  print(intBox); // Box(100)

  // intBox.replace('oops'); // Error: 'oops' isn't a subtype of int
}

The commented-out line is the entire point: the compiler enforces that intBox only ever holds ints, at zero runtime cost — no casting, no is checks needed later.

A generic function

Type parameters work on functions too, not just classes. Dart can usually infer T from the arguments, so you rarely need to write it explicitly at the call site.

T firstOrDefault<T>(List<T> items, T defaultValue) {
  return items.isEmpty ? defaultValue : items.first;
}

void main() {
  print(firstOrDefault<int>([1, 2, 3], 0));  // 1
  print(firstOrDefault<int>([], 0));         // 0
  print(firstOrDefault(['a', 'b'], 'none')); // a -- T inferred as String
}

Multiple type parameters

A generic type can take more than one parameter — a Pair<K, V> needs both a key type and a value type, for example.

class Pair<K, V> {
  final K key;
  final V value;
  Pair(this.key, this.value);

  @override
  String toString() => '$key -> $value';
}

void main() {
  final pair = Pair<String, int>('age', 30);
  print(pair); // age -> 30

  final pairs = [Pair('a', 1), Pair('b', 2)];
  for (final p in pairs) {
    print(p);
  }
  // a -> 1
  // b -> 2
}

Bounded type parameters — T extends SomeType

Sometimes generic code needs to call methods on T, not just store and return it. T extends Comparable<T> restricts what can fill in T, while guaranteeing every valid T supports compareTo — the class can then use it safely.

class Leaderboard<T extends Comparable<T>> {
  final List<T> _entries = [];

  void add(T entry) => _entries.add(entry);

  T get highest {
    // Safe to call compareTo -- the bound guarantees every T has it.
    return _entries.reduce((a, b) => a.compareTo(b) >= 0 ? a : b);
  }
}

class Score implements Comparable<Score> {
  final String player;
  final int points;
  Score(this.player, this.points);

  @override
  int compareTo(Score other) => points.compareTo(other.points);

  @override
  String toString() => '$player: $points';
}

void main() {
  final board = Leaderboard<Score>();
  board.add(Score('Ada', 50));
  board.add(Score('Grace', 90));
  board.add(Score('Alan', 70));
  print(board.highest); // Grace: 90
}

Without the extends Comparable<T> bound, a.compareTo(b) wouldn't compile — the analyzer has no way to know an arbitrary T supports it. Leaderboard<int> also works out of the box, since int already implements Comparable<int>.

The trap: generics aren't just "typed dynamic"

It's tempting to reach for dynamic instead of a type parameter when writing reusable code — they can look similar at a glance, but they behave completely differently. dynamic opts a value out of compile-time checking entirely; a generic type parameter keeps full checking, just parameterized.

class Stack<T> {
  final List<T> _items = [];

  void push(T item) => _items.add(item);

  T pop() {
    if (_items.isEmpty) throw StateError('Cannot pop from an empty stack');
    return _items.removeLast();
  }

  bool get isEmpty => _items.isEmpty;
}

void main() {
  final stack = Stack<int>();
  stack.push(1);
  stack.push(2);
  // stack.push('three'); // Error: caught at compile time, not runtime

  print(stack.pop()); // 2 -- still an int, no cast needed

  // The type parameter is preserved at runtime too, and checkable:
  print(stack is Stack<int>); // true
}

Had Stack been written with List<dynamic> instead of List<T>, pushing a String onto an int stack would compile fine and blow up later, far from the mistake, the first time something tried to use the popped value as an int. Generics catch that at the call site instead.

Approach Compile-time safety Typical use
dynamic None — anything goes, checked at runtime if at all Genuinely unknown/mixed JSON-like data
T (generic) Full — enforced per instantiation (Stack<int> vs Stack<String>) Reusable containers/algorithms over a caller-chosen type
T extends Bound Full, plus lets you call Bound's methods on T Generic code that needs to compare, hash, or otherwise operate on T

How It Actually Works

Dart generics use type erasure with reified type arguments — a combination that's easy to state wrong. At compile time, a generic class like Box<T> has all of T's usages checked against the bound (T extends SomeType restricts what members you can call on values of type T inside the class body). But unlike Java's fully-erased generics, Dart keeps the concrete type argument around at runtime as part of the object's actual runtime type — Box<int> and Box<String> are genuinely different runtime types, which is why x is Box<int> and x.runtimeType give meaningful, distinct answers at runtime, and why a List<int> can't have a String appended to it even via an Object reference to the same list — the runtime enforces the reified type argument, not just the compiler.

This reification is exactly what separates real generics from "typed dynamic": a dynamic container performs no type checking at all on insertion or extraction, while List<T> inserts a genuine runtime type check whenever you add an element through a reference whose static type doesn't already guarantee the element type matches — you can trigger this by upcasting List<int> to List<num> and then trying to add a double through that reference, which throws at runtime because the underlying object is still, at its core, a reified List<int>.

Bounded type parameters (T extends Comparable<T>) let the compiler resolve member calls on T-typed values statically, at compile time, against the bound's interface — calling a.compareTo(b) inside a generic function constrained this way compiles to a normal virtual dispatch through Comparable's interface, with no runtime type inspection needed to know compareTo exists.

Exercise

Write a generic class Cache<K, V> with a private Map<K, V> _store, a method V? get(K key), a method void set(K key, V value), and a method V getOrCompute(K key, V Function() compute) that returns the cached value if present, otherwise calls compute(), stores the result, and returns it. Test it with Cache<String, int>, using getOrCompute to memoize an expensive-looking computation (print a message each time compute actually runs, and confirm it only runs once per key across repeated calls).