Generics — Type Safety & Generic Classes
Generics — Type Safety & Generic Classes
Generics are one of the most powerful features in Dart. They let you write reusable, type-safe code that works with any data type — without duplicating logic or losing the benefits of Dart's static type system.
Why Generics?
Imagine you want to write a function that returns the first element of a list. Without generics, you'd have to write a separate version for each type:
dart// Without generics — duplicated code! int firstInt(List<int> list) => list.first; String firstString(List<String> list) => list.first; double firstDouble(List<double> list) => list.first;
Or you could use dynamic, which loses all type safety:
dart// With dynamic — type safety gone! dynamic firstDynamic(List list) => list.first; // No compile-time protection: String result = firstDynamic([1, 2, 3]); // Runtime error!
Generics solve this perfectly — write once, use with any type, keep full type safety:
dartT first<T>(List<T> list) => list.first; // Type-safe usage: int a = first([1, 2, 3]); // ✅ inferred as int String b = first(['a', 'b']); // ✅ inferred as String // int c = first(['x', 'y']); // ❌ compile error — String ≠ int
Type Parameter Naming Conventions
Dart follows standard conventions for naming generic type parameters:
| Name | Convention |
|---|---|
T | General type (Type) |
E | Element type (used in collections) |
K | Key type (used in maps) |
V | Value type (used in maps) |
R | Return type |
S, U, W | Additional types when T, E are taken |
Generic Functions
A generic function declares one or more type parameters in angle brackets <T> after the function name:
dart// Returns the first element T findFirst<T>(List<T> list) { if (list.isEmpty) throw StateError('List is empty'); return list.first; } // Returns the last element T findLast<T>(List<T> list) { if (list.isEmpty) throw StateError('List is empty'); return list.last; } // Swaps two elements, returns a new pair (T, T) swap<T>(T a, T b) => (b, a); // Filters a list by a predicate List<T> filter<T>(List<T> list, bool Function(T) predicate) { return list.where(predicate).toList(); } // Maps a list to a different type List<R> transform<T, R>(List<T> list, R Function(T) mapper) { return list.map(mapper).toList(); } void main() { print(findFirst([10, 20, 30])); // 10 print(findFirst(['apple', 'banana'])); // apple print(findLast([1.1, 2.2, 3.3])); // 3.3 var (x, y) = swap('hello', 'world'); print('\$x \$y'); // world hello var evens = filter([1, 2, 3, 4, 5, 6], (n) => n.isEven); print(evens); // [2, 4, 6] var lengths = transform(['dart', 'is', 'fun'], (s) => s.length); print(lengths); // [4, 2, 3] }
Generic Classes
You can make entire classes generic by adding <T> after the class name:
Example: Stack<T>
A classic stack (Last-In-First-Out) data structure:
dartclass Stack<T> { final List<T> _items = []; void push(T item) => _items.add(item); T pop() { if (isEmpty) throw StateError('Stack is empty'); return _items.removeLast(); } T get peek { if (isEmpty) throw StateError('Stack is empty'); return _items.last; } bool get isEmpty => _items.isEmpty; int get size => _items.length; String toString() => 'Stack\$_items'; } void main() { // Integer stack var intStack = Stack<int>(); intStack.push(1); intStack.push(2); intStack.push(3); print(intStack); // Stack[1, 2, 3] print(intStack.pop()); // 3 print(intStack.peek); // 2 // String stack var strStack = Stack<String>(); strStack.push('a'); strStack.push('b'); print(strStack.pop()); // b // intStack.push('hello'); // ❌ Compile error — type safety! }
Example: Pair<K, V> — Multiple Type Parameters
A class can have multiple type parameters:
dartclass Pair<K, V> { final K key; final V value; const Pair(this.key, this.value); Pair<V, K> swap() => Pair(value, key); String toString() => 'Pair(\$key, \$value)'; } void main() { var p1 = Pair<String, int>('age', 25); print(p1); // Pair(age, 25) var p2 = p1.swap(); print(p2); // Pair(25, age) var p3 = Pair('Dart', 3.14); print(p3.key); // Dart print(p3.value); // 3.14 }
Bounded Generics — <T extends SomeType>
Sometimes you want to accept only types that have certain capabilities. Use extends to constrain a type parameter:
dart// T must implement Comparable (has compareTo method) T findMax<T extends Comparable<T>>(List<T> list) { if (list.isEmpty) throw StateError('List is empty'); T max = list.first; for (var item in list) { if (item.compareTo(max) > 0) max = item; } return max; } void main() { print(findMax([3, 1, 4, 1, 5, 9, 2, 6])); // 9 print(findMax(['banana', 'apple', 'cherry'])); // cherry print(findMax([3.14, 2.71, 1.41])); // 3.14 // findMax([Object(), Object()]); // ❌ Object doesn't implement Comparable }
Custom Bounded Type:
dartabstract class Printable { void printInfo(); } class Report extends Printable { String title; Report(this.title); void printInfo() => print('Report: \$title'); } void printAll<T extends Printable>(List<T> items) { for (var item in items) { item.printInfo(); // Safe — T is guaranteed to have printInfo() } } void main() { var reports = [Report('Q1'), Report('Q2'), Report('Q3')]; printAll(reports); // Report: Q1 // Report: Q2 // Report: Q3 }
Generic Constraints with dart:collection
Dart's dart:collection and core libraries use generics everywhere:
dartimport 'dart:collection'; void main() { // Typed collections var queue = Queue<int>(); queue.add(1); queue.add(2); queue.add(3); print(queue.removeFirst()); // 1 var linkedMap = LinkedHashMap<String, int>(); linkedMap['a'] = 1; linkedMap['b'] = 2; print(linkedMap); // {a: 1, b: 2} // Set with generic type var uniqueNames = <String>{}; uniqueNames.add('Alice'); uniqueNames.add('Bob'); uniqueNames.add('Alice'); // Duplicate — ignored print(uniqueNames); // {Alice, Bob} }
Real Example 1: Typed Cache
dartclass Cache<T> { final Map<String, T> _store = {}; final Duration _ttl; final Map<String, DateTime> _timestamps = {}; Cache({Duration ttl = const Duration(minutes: 5)}) : _ttl = ttl; void set(String key, T value) { _store[key] = value; _timestamps[key] = DateTime.now(); } T? get(String key) { if (!_store.containsKey(key)) return null; final age = DateTime.now().difference(_timestamps[key]!); if (age > _ttl) { _store.remove(key); _timestamps.remove(key); return null; // Expired } return _store[key]; } bool contains(String key) => _store.containsKey(key); void clear() { _store.clear(); _timestamps.clear(); } int get size => _store.length; } void main() { var userCache = Cache<String>(ttl: Duration(seconds: 60)); userCache.set('user:1', 'Alice'); userCache.set('user:2', 'Bob'); print(userCache.get('user:1')); // Alice print(userCache.get('user:99')); // null — not found print(userCache.size); // 2 var scoreCache = Cache<int>(); scoreCache.set('level:1', 1500); print(scoreCache.get('level:1')); // 1500 }
Real Example 2: Generic Repository Pattern
dartabstract class Repository<T, ID> { void save(ID id, T item); T? findById(ID id); List<T> findAll(); void delete(ID id); } class User { final int id; final String name; final String email; User({required this.id, required this.name, required this.email}); String toString() => 'User(id: \$id, name: \$name)'; } class InMemoryUserRepository implements Repository<User, int> { final Map<int, User> _db = {}; void save(int id, User user) => _db[id] = user; User? findById(int id) => _db[id]; List<User> findAll() => _db.values.toList(); void delete(int id) => _db.remove(id); List<User> findByName(String name) => _db.values.where((u) => u.name.contains(name)).toList(); } void main() { var repo = InMemoryUserRepository(); repo.save(1, User(id: 1, name: 'Alice', email: 'alice@example.com')); repo.save(2, User(id: 2, name: 'Bob', email: 'bob@example.com')); repo.save(3, User(id: 3, name: 'Alice Smith', email: 'alice.s@example.com')); print(repo.findById(2)); // User(id: 2, name: Bob) print(repo.findAll()); // [User(id: 1, ...), ...] print(repo.findByName('Alice')); // [User(id: 1, ...), User(id: 3, ...)] repo.delete(1); print(repo.findAll().length); // 2 }
Summary
| Concept | Syntax | Purpose |
|---|---|---|
| Generic function | T fn<T>(T arg) | Reusable function for any type |
| Generic class | class Box<T> | Reusable class for any type |
| Multiple params | class Pair<K, V> | Multiple type parameters |
| Bounded generic | <T extends Comparable> | Restrict to types with certain capabilities |
| Type inference | var s = Stack<int>() | Dart infers types from context |
| Type safety | Compile-time checking | Errors caught before runtime |
[!TIP]
Use generics whenever you find yourself writing the same logic for different types. If your code works identically for String, int, and custom objects — it should probably be generic!
In the next lesson, we'll learn how to handle things that go wrong with Exception Handling.