Generics: Writing Type-Safe Reusable Code
You have already used generics without noticing. When you wrote List<String> or Map<String, int>, the angle brackets were generics at work. This chapter explains what those brackets really mean, why they exist, and how to write your own generic functions and classes so you can reuse the same code with many types — safely.
The Problem Generics Solve
Imagine you want a small container that holds one item. Without generics, you have two bad options.
Option one: make a container that holds Object, the type that every Dart value has.
class Box {
Object? _item;
void put(Object item) => _item = item;
Object? get item => _item;
}
void main() {
final box = Box();
box.put('hello');
final item = box.item;
// The compiler only knows this is Object?, not String.
// We must "cast" it, which trusts us at run time:
print((item as String).toUpperCase());
}The cast as String is a promise to the compiler: "trust me, it really is a String." If that promise is wrong, the program crashes when it runs. The compiler cannot help you.
Option two: write one container per type — StringBox, IntBox, DateBox — and copy-paste the same logic into each. Now your code grows, and a bug fix must be repeated everywhere.
Generics give a third option: write the container once, with a placeholder for the type, and fill in the placeholder each time you use it.
class Box<T> {
T? _item;
void put(T item) => _item = item;
T? get item => _item;
}
void main() {
final stringBox = Box<String>();
stringBox.put('hello');
print(stringBox.item?.toUpperCase()); // no cast needed
final intBox = Box<int>();
intBox.put(42);
print(intBox.item);
}T is a type variable — a name that stands for "some type, chosen later." Box<String> is a parameterized type: the Box recipe with T filled in as String.
What "Type-Safe" Buys You
Type-safe means the compiler checks that the types line up before the program runs. Compare the two boxes:
final safeBox = Box<String>();
safeBox.put(42); // compile-time error: caught before running
final riskyBox = OldBox();
riskyBox.put(42);
riskyBox.item as String; // run-time crash: caught only when it hurtsErrors that surface at compile time cost seconds to fix. Errors that surface at run time may reach real users. Generics move whole categories of mistakes from the second group to the first.
Generic Functions
Functions can have type variables too. Here is a firstOrNull helper that works on any list:
T? firstOrNull<T>(List<T> items) {
if (items.isEmpty) return null;
return items[0];
}
void main() {
print(firstOrNull<String>(['a', 'b'])); // a
print(firstOrNull<int>([])); // null
print(firstOrNull(['x', 'y'])); // Dart infers T as String
}The return type T? says: "I return either a T or nothing." In the last call we never wrote <String>; Dart inferred it from the argument's type, just as it infers variable types. Most of the time you can leave the angle brackets off a function call.
A more interesting example — swapping the first two elements of a list, for any element type:
void swapFirstTwo<T>(List<T> items) {
if (items.length < 2) return;
final first = items[0];
items[0] = items[1];
items[1] = first;
}
void main() {
final words = ['a', 'b', 'c'];
swapFirstTwo(words);
print(words); // [b, a, c]
}Try writing this with Object instead of T: it would accept the list but then refuse to put an Object back into a List<String>. The type variable is what makes the function honest about what it takes and returns.
Generic Classes: A Real Worked Example
Let us build a small Cache<K, V> — a place to store values under keys. It uses two type variables at once, exactly like Dart's built-in Map<K, V>:
class Cache<K, V> {
final Map<K, V> _store = {};
void put(K key, V value) => _store[key] = value;
V? get(K key) => _store[key];
V getOrPut(K key, V Function() makeValue) {
final existing = _store[key];
if (existing != null) return existing;
final created = makeValue();
_store[key] = created;
return created;
}
}
void main() {
final userNames = Cache<int, String>();
userNames.put(1, 'Ada');
print(userNames.get(1)); // Ada
print(userNames.get(2)); // null
print(userNames.getOrPut(2, () => 'Grace')); // Grace, then cached
}Notice getOrPut: it takes a function that makes a value, so an expensive value is computed only when it is actually missing. Also notice V? as the return of get — a generic class can still combine with null safety. A key that is absent returns "no value," and the compiler forces callers to handle that.
Constraints: Limiting What T Can Be
Sometimes you need to assume something about T. Suppose you want a function that returns the larger of two values. Comparison requires a method, so we constrain T with the extends keyword:
class Named {
final String name;
Named(this.name);
@override
String toString() => name;
}
T pickLarger<T extends Comparable>(T a, T b) {
return a.compareTo(b) >= 0 ? a : b;
}
void main() {
print(pickLarger(3, 7)); // 7
print(pickLarger('apple', 'pear')); // pear
}T extends Comparable means: "T can be any type, as long as it is a Comparable" — a type that promises a compareTo method. Inside the function, the compiler now lets you call compareTo, because every legal T has one. Without the constraint, that call would be a compile error.
This is the balance of generics: unconstrained code works with everything but can assume nothing; constrained code gives up some universality to gain some capability.
Where You Will Meet Generics
You will use generics constantly in Dart, mostly on the receiving end:
| Construct | Meaning |
|---|---|
| `List<int>` | A list whose elements are all `int` |
| `Set<String>` | A set of unique strings |
| `Map<String, int>` | Keys are strings, values are integers |
| `Future<String>` | An asynchronous operation that produces a string |
| `Stream<int>` | A sequence of asynchronous integer events |
| `Iterable<T>` | A sequence of `T` you can step through |
That last two rows matter for later chapters: Future<T> and Stream<T> are generic classes, so everything you learned here applies directly to asynchronous code. A Future<String> is not "a future of anything" — it is specifically a promise of a string, and the compiler will hold you to that.
Exercises
Exercise
Write a generic function lastOf<T> that takes a List<T> and returns T? — the last element, or null if the list is empty. Then call it once with a List<String> and once with a List<double>, letting Dart infer the type.
Show answer
T? lastOf<T>(List<T> items) => items.isEmpty ? null : items[items.length - 1]; void main() { print(lastOf(['a', 'b'])); // b print(lastOf([1.5, 2.5])); // 2.5 print(lastOf<String>([])); // null }
Exercise
What is the difference between class Box { Object? _item; } and class Box<T> { T? _item; } from the caller's point of view?
Show answer
With Object?, every box accepts anything, and callers must cast values back to the type they expect — risking a run-time crash if the cast is wrong. With Box<T>, each caller picks one type (e.g. Box<String>), the compiler rejects wrong-type values at compile time, and no casts are needed because values come back already typed as T.
Exercise
Write a generic class Pair<A, B> holding two values of possibly different types, with a swap() method that returns a new Pair<B, A>. Use it to pair a name with an age and then swap it.
Show answer
class Pair<A, B> { final A first; final B second; Pair(this.first, this.second); Pair<B, A> swap() => Pair(second, first); } void main() { final p = Pair<String, int>('Ada', 36); final swapped = p.swap(); // Pair<int, String> print('${swapped.first} ${swapped.second}'); // 36 Ada }
Spotted a mistake, or code that does not work?