Generics Fundamentals
Why Generics?
Imagine you build a Box class that holds an item. Without generics, you have two bad options: (1) make it hold dynamic, losing all type safety, or (2) create separate classes like IntBox, StringBox, UserBox -- duplicating code endlessly. Generics solve this by letting you write a single Box<T> that works with any type while keeping full type safety. The T is a type parameter -- a placeholder that gets replaced with a real type when you use the class.
You already use generics every day: List<int>, Map<String, dynamic>, Future<String> -- these are all generic types. Now you will learn to create your own.
The Problem Without Generics
// BAD: Using dynamic -- no type safety
class DynamicBox {
dynamic content;
DynamicBox(this.content);
}
void main() {
var box = DynamicBox(42);
// Compiler allows this -- but it will crash at runtime!
String text = box.content; // Runtime error: int is not String
}
// BAD: Separate classes for each type -- code duplication
class IntBox {
int content;
IntBox(this.content);
}
class StringBox {
String content;
StringBox(this.content);
}
// What about UserBox, ProductBox, OrderBox...?
// This does not scale!
The Solution: Generics
// GOOD: One class works for ALL types with full type safety
class Box<T> {
T content;
Box(this.content);
T open() {
print('Opening box containing: $content');
return content;
}
}
void main() {
// Dart infers the type from the argument
var intBox = Box(42); // Box<int>
var strBox = Box('Hello'); // Box<String>
// Or specify explicitly
Box<double> dblBox = Box(3.14);
int number = intBox.open(); // Type-safe: returns int
String text = strBox.open(); // Type-safe: returns String
// Compile-time error -- caught before you even run the code!
// String wrong = intBox.open(); // Error: int cannot be assigned to String
}
T is just a convention for “Type”. You can use any name: E for element, K and V for key/value, R for return type. The standard conventions are: T (Type), E (Element), K (Key), V (Value), S (State), R (Result).Generic Functions
You can also make individual functions generic without creating a generic class. This is useful for utility functions that work with any type.
Generic Functions
// A generic function -- T is declared after the function name
T firstElement<T>(List<T> items) {
if (items.isEmpty) {
throw StateError('List is empty');
}
return items.first;
}
// Generic function that transforms a value
R transform<T, R>(T value, R Function(T) transformer) {
return transformer(value);
}
// Generic swap function
(T, T) swap<T>(T a, T b) => (b, a);
void main() {
// Type is inferred from the argument
int first = firstElement([10, 20, 30]); // T inferred as int
String word = firstElement(['a', 'b', 'c']); // T inferred as String
// Explicit type parameter
double d = firstElement<double>([1.1, 2.2]);
// Transform: int to String
String result = transform(42, (n) => 'Number: $n');
print(result); // Number: 42
// Transform: String to int
int length = transform('Hello', (s) => s.length);
print(length); // 5
// Swap
var (a, b) = swap(1, 2);
print('$a, $b'); // 2, 1
}
Generic Classes
Generic classes are the most common use of generics. You declare one or more type parameters after the class name, then use those types throughout the class for fields, method parameters, and return types.
Building a Generic Stack
class Stack<T> {
final List<T> _items = [];
void push(T item) => _items.add(item);
T pop() {
if (_items.isEmpty) {
throw StateError('Stack is empty');
}
return _items.removeLast();
}
T get peek {
if (_items.isEmpty) {
throw StateError('Stack is empty');
}
return _items.last;
}
bool get isEmpty => _items.isEmpty;
bool get isNotEmpty => _items.isNotEmpty;
int get length => _items.length;
@override
String toString() => 'Stack(${_items.join(', ')})';
}
void main() {
// Stack of integers
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
// Stack of strings -- same class, different type
var strStack = Stack<String>();
strStack.push('hello');
strStack.push('world');
print(strStack.pop()); // world
// Type safety: cannot push wrong type
// intStack.push('text'); // Compile error!
}
Type Constraints with extends
Sometimes you need to restrict what types can be used with your generic. For example, a sort function should only work with Comparable types. You use extends to set an upper bound on the type parameter.
Bounded Generics
// T must be a subtype of Comparable<T>
T findMin<T extends Comparable<T>>(List<T> items) {
if (items.isEmpty) throw StateError('Empty list');
T smallest = items.first;
for (var item in items.skip(1)) {
if (item.compareTo(smallest) < 0) {
smallest = item;
}
}
return smallest;
}
// T must extend num -- so you can do math operations
class Statistics<T extends num> {
final List<T> data;
Statistics(this.data) {
if (data.isEmpty) throw ArgumentError('Data cannot be empty');
}
double get mean => data.reduce((a, b) => (a + b) as T) / data.length;
T get max => data.reduce((a, b) => a > b ? a : b);
T get min => data.reduce((a, b) => a < b ? a : b);
@override
String toString() => 'Stats(mean: ${mean.toStringAsFixed(2)}, min: $min, max: $max)';
}
void main() {
// Works with int (int extends Comparable<int>)
print(findMin([5, 2, 8, 1, 9])); // 1
// Works with String (String extends Comparable<String>)
print(findMin(['banana', 'apple', 'cherry'])); // apple
// Would NOT compile with a non-Comparable type:
// findMin([Object(), Object()]); // Error!
var intStats = Statistics([10, 20, 30, 40, 50]);
print(intStats); // Stats(mean: 30.00, min: 10, max: 50)
var dblStats = Statistics([1.5, 2.7, 3.14, 0.99]);
print(dblStats); // Stats(mean: 2.08, min: 0.99, max: 3.14)
// Would NOT compile:
// Statistics<String>(['a', 'b']); // Error: String does not extend num
}
T defaults to Object?, meaning it could be anything including null. If you write T extends Object, you exclude nullable types. If you write T extends num, you restrict to numeric types. Always add constraints when your code assumes certain capabilities of the type (like comparison or arithmetic).The Result<T> Pattern
One of the most practical uses of generics is the Result pattern -- a type-safe way to represent success or failure without using exceptions for expected errors. This pattern is used heavily in production Flutter apps.
Building a Result<T> Type
// A sealed class with two subtypes: Success and Failure
sealed class Result<T> {
const Result();
// Factory constructors for convenience
factory Result.success(T value) = Success<T>;
factory Result.failure(String message, [Exception? exception]) =
Failure<T>;
// Pattern matching helper
R when<R>({
required R Function(T value) success,
required R Function(String message, Exception? exception) failure,
}) => switch (this) {
Success(:final value) => success(value),
Failure(:final message, :final exception) => failure(message, exception),
};
}
class Success<T> extends Result<T> {
final T value;
const Success(this.value);
}
class Failure<T> extends Result<T> {
final String message;
final Exception? exception;
const Failure(this.message, [this.exception]);
}
// Usage in a repository
class UserRepository {
Future<Result<String>> fetchUserName(int id) async {
try {
// Simulate API call
if (id <= 0) {
return Result.failure('Invalid user ID');
}
await Future.delayed(Duration(milliseconds: 100));
return Result.success('User_$id');
} on Exception catch (e) {
return Result.failure('Network error', e);
}
}
}
void main() async {
var repo = UserRepository();
// Success case
var result = await repo.fetchUserName(42);
var message = result.when(
success: (name) => 'Welcome, $name!',
failure: (msg, _) => 'Error: $msg',
);
print(message); // Welcome, User_42!
// Failure case
var bad = await repo.fetchUserName(-1);
print(bad.when(
success: (name) => 'Got: $name',
failure: (msg, _) => 'Failed: $msg',
)); // Failed: Invalid user ID
// Pattern matching with switch
switch (result) {
case Success(:final value):
print('Success: $value');
case Failure(:final message):
print('Failure: $message');
}
}
Result<T> pattern is one of the most valuable patterns in Dart. Use it for operations that can fail in expected ways (API calls, file reads, validation). Reserve exceptions for truly unexpected situations (programming errors, out of memory). This makes your error handling explicit, testable, and impossible to accidentally ignore.Practical Example: Generic Repository Pattern
Let’s build a realistic pattern used in almost every Flutter app -- a generic repository that provides CRUD operations for any model type.
Real-World: Generic Repository
// Base class that all models must extend
abstract class Model {
final String id;
final DateTime createdAt;
Model({required this.id, required this.createdAt});
Map<String, dynamic> toJson();
}
// Generic repository that works with any Model subtype
class Repository<T extends Model> {
final Map<String, T> _store = {};
final String name;
Repository(this.name);
// Create
void add(T item) {
_store[item.id] = item;
print('[$name] Added: ${item.id}');
}
// Read
T? findById(String id) => _store[id];
List<T> findAll() => _store.values.toList();
List<T> findWhere(bool Function(T) predicate) =>
_store.values.where(predicate).toList();
// Delete
bool remove(String id) {
var removed = _store.remove(id) != null;
if (removed) print('[$name] Removed: $id');
return removed;
}
int get count => _store.length;
}
// Concrete models
class User extends Model {
final String name;
final String email;
User({required super.id, required this.name, required this.email})
: super(createdAt: DateTime.now());
@override
Map<String, dynamic> toJson() => {
'id': id, 'name': name, 'email': email,
};
@override
String toString() => 'User($name, $email)';
}
class Product extends Model {
final String title;
final double price;
Product({required super.id, required this.title, required this.price})
: super(createdAt: DateTime.now());
@override
Map<String, dynamic> toJson() => {
'id': id, 'title': title, 'price': price,
};
@override
String toString() => 'Product($title, \$$price)';
}
void main() {
// Same Repository class, different types
var users = Repository<User>('Users');
var products = Repository<Product>('Products');
users.add(User(id: 'u1', name: 'Alice', email: 'alice@test.com'));
users.add(User(id: 'u2', name: 'Bob', email: 'bob@test.com'));
products.add(Product(id: 'p1', title: 'Laptop', price: 999.99));
products.add(Product(id: 'p2', title: 'Phone', price: 699.99));
// Type-safe queries
User? alice = users.findById('u1');
print(alice); // User(Alice, alice@test.com)
// Filter products under $800
var affordable = products.findWhere((p) => p.price < 800);
print(affordable); // [Product(Phone, $699.99)]
// Cannot mix types -- compile error:
// users.add(Product(...)); // Error: Product is not User
}
UserRepository, ProductRepository, OrderRepository classes with identical CRUD logic. With Repository<T extends Model>, you write the logic once and it works safely with any model type.