Generics in Dart — Type Safety Without the Repetition
Write code once, use it with any type. How Dart's generics give us flexible, reusable APIs while keeping the type checker happy.
The problem — type safety vs code reuse
Imagine we need a box that holds one item. We want type safety — the compiler should catch mistakes. So we write a box for each type.
class IntBox {
final int value;
IntBox(this.value);
}
class StringBox {
final String value;
StringBox(this.value);
}
class UserBox {
final User value;
UserBox(this.value);
}
This works, but we're repeating ourselves. Every new type needs a new box class. The code is identical except for the type name.
The alternative is to use
dynamic and give up type safety.
class AnyBox {
final dynamic value;
AnyBox(this.value);
}
void main() {
var box = AnyBox(42);
String s = box.value; // compiles fine, crashes at runtime
}
Now we have one class, but the compiler cannot help us. Type errors become runtime errors.
Generics solve this. We write the code once with a placeholder type, and the compiler fills in the real type at each use site.
class Box<T> {
final T value;
Box(this.value);
}
void main() {
var intBox = Box<int>(42);
var stringBox = Box<String>('hello');
int n = intBox.value; // works
String s = stringBox.value; // works
String bad = intBox.value; // compile error — caught before runtime
}
One class. Full type safety. That's the power of generics.
Generic classes — List<T>, Map<K,V>, and your own
We've been using generics since Episode 6 — List<int>, Set<String>, Map<String, User>. Now let's see how they work under the hood.
A generic class declares one or more type parameters in angle brackets after the class name.
class Pair<A, B> {
final A first;
final B second;
Pair(this.first, this.second);
@override
String toString() => '($first, $second)';
}
void main() {
var coords = Pair<int, int>(10, 20);
var entry = Pair<String, double>('price', 9.99);
print(coords); // (10, 20)
print(entry); // (price, 9.99)
}
The type parameters A and B become concrete types when we instantiate the class. Inside the class body, we use them like any other type.
Dart's core collections are generic. Here's the mental model.
Convention for type parameter names. Single uppercase letters are standard:
T for a general type, E for element, K and V for key/value, R for return type. Longer names like Element are allowed but rare.
// A simple cache with generic key and value types
class Cache<K, V> {
final Map<K, V> _store = {};
V? get(K key) => _store[key];
void put(K key, V value) {
_store[key] = value;
}
void clear() => _store.clear();
}
void main() {
var userCache = Cache<int, User>();
userCache.put(1, User('Alice'));
var configCache = Cache<String, String>();
configCache.put('theme', 'dark');
}
Generic methods and functions
Methods and standalone functions can have their own type parameters, independent of any class.
T first<T>(List<T> items) {
if (items.isEmpty) throw StateError('Empty list');
return items[0];
}
void main() {
var numbers = [1, 2, 3];
var strings = ['a', 'b', 'c'];
int n = first(numbers); // T is int
String s = first(strings); // T is String
}
The type parameter <T> goes after the function name, before the parameters. Inside the function, T is a real type we can use for parameters, return types, and local variables.
Generic methods in classes. A method can introduce new type parameters even if the class has its own.
class Transformer<T> {
final T value;
Transformer(this.value);
// R is a new type parameter, specific to this method
R transform<R>(R Function(T) mapper) {
return mapper(value);
}
}
void main() {
var t = Transformer<int>(42);
String s = t.transform((n) => n.toString()); // R = String
double d = t.transform((n) => n * 1.5); // R = double
}
When to use generic methods vs generic classes. If the type parameter is part of the object's identity (a List<int> stays an int list), put it on the class. If the type varies per operation (like map transforming elements to a different type), put it on the method.
Bounded type parameters — extends and upper bounds
Sometimes we need more than "any type." We need a type that supports specific operations. That's where bounds come in.
// This won't work — T might not have compareTo
T findMax<T>(List<T> items) {
var max = items[0];
for (var item in items) {
if (item.compareTo(max) > 0) max = item; // error!
}
return max;
}
The compiler doesn't know that T has a compareTo method. We need to tell it.
T findMax<T extends Comparable<T>>(List<T> items) {
var max = items[0];
for (var item in items) {
if (item.compareTo(max) > 0) max = item; // works!
}
return max;
}
void main() {
print(findMax([3, 1, 4, 1, 5])); // 5
print(findMax(['banana', 'apple', 'cherry'])); // cherry
}
The extends Comparable<T> is an upper bound. It says: "T must be a subtype of ComparablecompareTo exists.
Common bounds in practice.
// Must be a number
T sum<T extends num>(List<T> values) {
return values.reduce((a, b) => (a + b) as T);
}
// Must have a toJson method (via interface)
void save<T extends Serializable>(T item) {
var json = item.toJson();
// ...
}
// Multiple type parameters, each with bounds
class Repository<K extends Object, V extends Entity> {
V? findById(K id) { ... }
}
Bounds give us the best of both worlds: flexible code that still knows what operations it can perform.
Covariance — why List<Dog> works where List<Animal> is expected
Here's a puzzle. Dog extends Animal. We can pass a Dog where an Animal is expected. But what about List<Dog> and List<Animal>?
class Animal {}
class Dog extends Animal {}
class Cat extends Animal {}
void printAnimals(List<Animal> animals) {
for (var a in animals) print(a);
}
void main() {
var dogs = <Dog>[Dog(), Dog()];
printAnimals(dogs); // Does this work?
}
In Dart, this works. List<Dog> is a subtype of List<Animal>. This property is called covariance — the generic type varies in the same direction as its parameter.
Why is this safe? When we read from a
List<Dog>, we get Dogs. Dogs are Animals. So reading works. But writing is trickier.
void addCat(List<Animal> animals) {
animals.add(Cat()); // adding a Cat to a list of Animals
}
void main() {
var dogs = <Dog>[Dog(), Dog()];
addCat(dogs); // passes a List<Dog> as List<Animal>
// Now dogs contains a Cat — type safety broken?
}
Dart catches this at runtime. Adding a Cat to a List<Dog> throws a TypeError. The static type system allows the code, but runtime checks enforce the actual element type.
The practical lesson: Covariance is convenient for reading. It's safe to pass
List<Dog> to a function that only reads animals. But if the function modifies the list, expect runtime errors if types don't match.
Type inference — letting Dart figure it out
We don't always have to write out type arguments. Dart's type inference is smart enough to figure them out from context.
// Explicit type arguments
var explicit = <int>[1, 2, 3];
var explicitMap = <String, int>{'a': 1, 'b': 2};
// Inferred from the elements
var inferred = [1, 2, 3]; // List<int>
var inferredMap = {'a': 1, 'b': 2}; // Map<String, int>
// Inferred from the variable type
List<int> numbers = []; // empty List<int>, not List<dynamic>
Inference flows through generic methods too.
T first<T>(List<T> items) => items[0];
var numbers = [1, 2, 3];
var n = first(numbers); // T inferred as int, n is int
// Explicit when needed
var x = first<num>([1, 2, 3]); // force T to be num
When inference fails. Sometimes Dart needs help. Empty collections and complex chains can be ambiguous.
// Ambiguous — what type is this empty list?
var items = []; // List<dynamic> — probably not what we want
// Help Dart out
var items = <String>[]; // List<String>
List<String> items = []; // same result
// Complex chains might need hints
var result = things
.map((t) => t.name)
.where((n) => n.isNotEmpty)
.toList();
// Usually fine, but if 'things' has an unclear type, add hints
The rule: Let Dart infer when the type is obvious. Add explicit types when the inference would produce dynamic or the wrong type.
Reified generics — Dart remembers the type
Here's something that surprises developers from Java or TypeScript. In Dart, generic types are reified — they exist at runtime, not just compile time.
void main() {
var numbers = <int>[1, 2, 3];
var strings = <String>['a', 'b', 'c'];
print(numbers is List<int>); // true
print(numbers is List<String>); // false
print(strings is List<String>); // true
print(strings is List<int>); // false
}
We can ask "is this a List<int>?" at runtime and get a meaningful answer. This is not possible in Java (type erasure) or TypeScript (types disappear after compilation).
Practical uses of reified generics.
// Runtime type dispatch
void process(Object data) {
if (data is List<int>) {
print('Sum: ${data.reduce((a, b) => a + b)}');
} else if (data is List<String>) {
print('Joined: ${data.join(', ')}');
} else if (data is Map<String, dynamic>) {
print('Keys: ${data.keys}');
}
}
// Getting the runtime type
var items = <String>['a', 'b'];
print(items.runtimeType); // List<String>
Why does this matter? Frameworks can inspect types at runtime for serialisation, dependency injection, and reflection. Flutter's widget system relies on reified generics for type-safe builders. JSON decoders can check target types. This runtime knowledge is a genuine advantage Dart has over languages with type erasure.
Test your understanding
7 questions
Seven questions covering generics, bounds, covariance, inference, and reification.