Dart Tutorial

Dart Lesson 55 of 102 4 min read

Generics in Dart: Type-Safe Reusable Code

Learn generics in Dart: generic classes, functions and constraints with extends, plus covariance and the covariant keyword.

On this page

You have been using generics since the collections chapter. In List<String>, the <String> is a type argument: it tells the list what it holds. Generics let you write a class or function once and use it safely with many types.

The problem generics solve #

class IntBox {
  final int value;
  IntBox(this.value);
}

class StringBox {
  final String value;
  StringBox(this.value);
}
// ...and another class for every type?

You could use Object or dynamic to hold anything, but then you lose type checking and have to cast everywhere.

A generic class #

T is a type parameter: a placeholder that is filled in when the class is used.

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

  T open() => value;
}

void main() {
  var intBox = Box<int>(42);
  var textBox = Box('hello'); // T is inferred as String

  print(intBox.open() + 1);
  print(textBox.open().toUpperCase());
  // intBox.open().toUpperCase(); // error: int has no toUpperCase
}
43
HELLO

One class, full type safety, no casts.

Several type parameters #

class Pair<A, B> {
  final A first;
  final B second;
  const Pair(this.first, this.second);

  Pair<B, A> swap() => Pair(second, first);

  @override
  String toString() => '($first, $second)';
}

void main() {
  var p = Pair('age', 30);
  print(p);
  print(p.swap());
}
(age, 30)
(30, age)

By convention, type parameters are single capital letters: T for type, E for element, K and V for key and value.

Generic functions #

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

List<T> repeat<T>(T item, int times) => [for (var i = 0; i < times; i++) item];

void main() {
  print(firstOr([3, 4], 0));
  print(firstOr(<String>[], 'none'));
  print(repeat('ha', 3));
}
3
none
[ha, ha, ha]

Constraining a type with extends #

Inside Box<T>, Dart knows nothing about T, so you can only use what every object has. A bound promises more.

T largest<T extends Comparable<T>>(List<T> items) {
  var best = items.first;
  for (final item in items) {
    if (item.compareTo(best) > 0) best = item;
  }
  return best;
}

class Stats<T extends num> {
  final List<T> values;
  Stats(this.values);

  double get average =>
      values.fold<double>(0, (sum, v) => sum + v) / values.length;
}

void main() {
  print(largest(['pear', 'apple', 'zebra']));
  print(Stats([4, 8, 6]).average);
  // Stats(['a', 'b']); // error: String is not a num
}
zebra
6.0

A realistic example: a typed result #

class ApiResult<T> {
  final T? data;
  final String? error;

  const ApiResult.success(T this.data) : error = null;
  const ApiResult.failure(String this.error) : data = null;

  bool get isSuccess => error == null;
}

ApiResult<List<String>> loadNames(bool online) => online
    ? const ApiResult.success(['Asha', 'Bimal'])
    : const ApiResult.failure('No connection');

void main() {
  var result = loadNames(true);
  if (result.isSuccess) print(result.data!.length);

  print(loadNames(false).error);
}
2
No connection

Generics are kept at run time #

Unlike Java, Dart remembers type arguments while the program runs, so you can test them.

void main() {
  var names = <String>['a', 'b'];
  print(names is List<String>);
  print(names is List<int>);
}
true
false

Covariance: a List of Dog is a List of Animal #

In Dart, List<Dog> can be used where List<Animal> is expected. This is called covariance. It is convenient, but it has one trap, which Dart catches at run time instead of compile time.

class Animal {}

class Dog extends Animal {}

class Cat extends Animal {}

void main() {
  List<Dog> dogs = [Dog()];
  List<Animal> animals = dogs; // allowed

  print(animals.length);

  try {
    animals.add(Cat()); // the real list only holds dogs
  } catch (e) {
    print('Run-time type error');
  }
}
1
Run-time type error

Reading from such a list is always safe. Writing to it is where care is needed.

The covariant keyword #

Normally an overriding method must accept at least everything the parent method accepts. Occasionally a subclass sensibly wants a narrower parameter. Marking the parameter covariant allows it, and Dart checks the type at run time.

class Animal {
  void chase(covariant Animal other) => print('Chasing an animal');
}

class Mouse extends Animal {}

class Cat extends Animal {
  @override
  void chase(Mouse other) => print('Cat chases a mouse'); // narrower type
}

void main() {
  Cat().chase(Mouse());
}
Cat chases a mouse

Dart also understands contravariance for function types: a function that accepts any Animal can be used where a function accepting a Dog is required, because it can certainly handle dogs.

Try it yourself #

Write a generic Stack<T> class with push, pop, peek and isEmpty, backed by a private list. Use it once with int and once with String. Then write T? maxOf<T extends Comparable<T>>(List<T> items) that returns null for an empty list.

Practise in the playground Updated by Santosh Adhikari