Generic Classes & Interfaces
Generic Interfaces
In the previous lesson, you learned how to create generic classes and functions. Now we take it further: generic interfaces define contracts that use type parameters. Any class implementing a generic interface must provide concrete types and implement all required members for those types. This is the foundation for building flexible, reusable architectures in Dart.
In Dart, there is no separate interface keyword -- any class or abstract class can serve as an interface. With Dart 3, you can also use abstract interface class to create a pure interface that cannot be extended (only implemented).
Defining a Generic Interface
// A generic interface for data storage
// T is the type of item stored
abstract interface class DataStore<T> {
Future<T?> getById(String id);
Future<List<T>> getAll();
Future<void> save(T item);
Future<bool> delete(String id);
Future<int> get count;
}
// A generic interface for items that can be converted to/from JSON
abstract interface class JsonConvertible<T> {
Map<String, dynamic> toJson();
T fromJson(Map<String, dynamic> json);
}
// Implementing with a concrete type
class User {
final String id;
final String name;
final String email;
User({required this.id, required this.name, required this.email});
Map<String, dynamic> toJson() => {'id': id, 'name': name, 'email': email};
factory User.fromJson(Map<String, dynamic> json) => User(
id: json['id'] as String,
name: json['name'] as String,
email: json['email'] as String,
);
@override
String toString() => 'User($name, $email)';
}
// Concrete implementation of DataStore for User
class InMemoryUserStore implements DataStore<User> {
final Map<String, User> _data = {};
@override
Future<User?> getById(String id) async => _data[id];
@override
Future<List<User>> getAll() async => _data.values.toList();
@override
Future<void> save(User item) async => _data[item.id] = item;
@override
Future<bool> delete(String id) async => _data.remove(id) != null;
@override
Future<int> get count async => _data.length;
}
void main() async {
DataStore<User> store = InMemoryUserStore();
await store.save(User(id: '1', name: 'Alice', email: 'alice@test.com'));
await store.save(User(id: '2', name: 'Bob', email: 'bob@test.com'));
var user = await store.getById('1');
print(user); // User(Alice, alice@test.com)
print(await store.count); // 2
}
implements DataStore<User>, Dart replaces every T in the interface with User. So Future<T?> getById(String id) becomes Future<User?> getById(String id). The compiler enforces this -- you cannot return the wrong type.Implementing Generic Interfaces with Generics
You can also implement a generic interface while keeping the type parameter generic -- creating a generic implementation that works with any type. This is how you build truly reusable components.
Generic Implementation of a Generic Interface
// Generic interface
abstract interface class Cache<K, V> {
V? get(K key);
void set(K key, V value, {Duration? ttl});
void remove(K key);
void clear();
int get size;
}
// Generic implementation -- works with ANY key and value types
class InMemoryCache<K, V> implements Cache<K, V> {
final Map<K, _CacheEntry<V>> _store = {};
final int maxSize;
InMemoryCache({this.maxSize = 100});
@override
V? get(K key) {
var entry = _store[key];
if (entry == null) return null;
if (entry.isExpired) {
_store.remove(key);
return null;
}
return entry.value;
}
@override
void set(K key, V value, {Duration? ttl}) {
// Evict oldest if at capacity
if (_store.length >= maxSize && !_store.containsKey(key)) {
_store.remove(_store.keys.first);
}
_store[key] = _CacheEntry(value, ttl: ttl);
}
@override
void remove(K key) => _store.remove(key);
@override
void clear() => _store.clear();
@override
int get size => _store.length;
}
class _CacheEntry<V> {
final V value;
final DateTime createdAt;
final Duration? ttl;
_CacheEntry(this.value, {this.ttl}) : createdAt = DateTime.now();
bool get isExpired =>
ttl != null && DateTime.now().difference(createdAt) > ttl!;
}
void main() {
// Cache with String keys and int values
var scores = InMemoryCache<String, int>(maxSize: 50);
scores.set('alice', 95);
scores.set('bob', 87);
print(scores.get('alice')); // 95
// Cache with int keys and Map values
var apiCache = InMemoryCache<int, Map<String, dynamic>>();
apiCache.set(42, {'name': 'Product 42', 'price': 9.99});
print(apiCache.get(42)); // {name: Product 42, price: 9.99}
// Same class, completely different types -- fully type-safe
// scores.set('charlie', 'ninety'); // Compile error: String is not int
}
abstract interface class (Dart 3) when you want a pure contract that cannot be extended, only implemented. Use abstract class when you want a mix of contract and shared implementation. The choice affects how other developers can use your type.Generic Methods in Classes
A class does not need to be generic itself to have generic methods. You can add type parameters to individual methods within a regular class. This is useful when a specific method needs flexibility that the class as a whole does not need.
Generic Methods Inside a Non-Generic Class
class DataProcessor {
// This class is NOT generic, but these methods are
// Generic method: transforms a list using a provided function
List<R> mapList<T, R>(List<T> items, R Function(T) transform) {
return items.map(transform).toList();
}
// Generic method: groups items by a key
Map<K, List<T>> groupBy<T, K>(List<T> items, K Function(T) keyFn) {
var result = <K, List<T>>{};
for (var item in items) {
var key = keyFn(item);
result.putIfAbsent(key, () => []).add(item);
}
return result;
}
// Generic method: finds first match or returns default
T findFirstOr<T>(List<T> items, bool Function(T) test, T defaultValue) {
for (var item in items) {
if (test(item)) return item;
}
return defaultValue;
}
}
void main() {
var processor = DataProcessor();
// mapList: int -> String
var labels = processor.mapList(
[1, 2, 3],
(n) => 'Item #$n',
);
print(labels); // [Item #1, Item #2, Item #3]
// groupBy: group strings by length
var words = ['cat', 'dog', 'fish', 'ant', 'bird'];
var grouped = processor.groupBy(words, (w) => w.length);
print(grouped); // {3: [cat, dog, ant], 4: [fish, bird]}
// findFirstOr with default
var numbers = [10, 25, 3, 42, 8];
var big = processor.findFirstOr(numbers, (n) => n > 30, -1);
print(big); // 42
var none = processor.findFirstOr(numbers, (n) => n > 100, -1);
print(none); // -1
}
Multiple Type Parameters
Generic classes and interfaces can have multiple type parameters, separated by commas. This is common for types that associate two or more related types, like key-value pairs or either/or types.
Pair and Either with Multiple Type Parameters
// A simple pair of two values of different types
class Pair<A, B> {
final A first;
final B second;
const Pair(this.first, this.second);
// Create a new pair with swapped positions
Pair<B, A> swap() => Pair(second, first);
// Transform one or both values
Pair<C, B> mapFirst<C>(C Function(A) transform) =>
Pair(transform(first), second);
Pair<A, C> mapSecond<C>(C Function(B) transform) =>
Pair(first, transform(second));
@override
String toString() => 'Pair($first, $second)';
@override
bool operator ==(Object other) =>
other is Pair<A, B> && other.first == first && other.second == second;
@override
int get hashCode => Object.hash(first, second);
}
// Either: holds a value of type A OR type B, never both
sealed class Either<L, R> {
const Either();
T fold<T>({
required T Function(L) left,
required T Function(R) right,
});
bool get isLeft;
bool get isRight;
}
class Left<L, R> extends Either<L, R> {
final L value;
const Left(this.value);
@override
T fold<T>({required T Function(L) left, required T Function(R) right}) =>
left(value);
@override
bool get isLeft => true;
@override
bool get isRight => false;
}
class Right<L, R> extends Either<L, R> {
final R value;
const Right(this.value);
@override
T fold<T>({required T Function(L) left, required T Function(R) right}) =>
right(value);
@override
bool get isLeft => false;
@override
bool get isRight => true;
}
void main() {
// Pair usage
var nameAge = Pair('Alice', 30);
print(nameAge); // Pair(Alice, 30)
print(nameAge.swap()); // Pair(30, Alice)
print(nameAge.mapFirst((n) => n.toUpperCase())); // Pair(ALICE, 30)
// Either: represents success (Right) or error (Left)
Either<String, int> result = Right(42);
var message = result.fold(
left: (error) => 'Error: $error',
right: (value) => 'Success: $value',
);
print(message); // Success: 42
Either<String, int> error = Left('Not found');
print(error.fold(
left: (e) => 'Error: $e',
right: (v) => 'Value: $v',
)); // Error: Not found
}
MyClass<A, B, C, D, E>, your design is probably too complex. Most real-world generics use one or two type parameters. Three is uncommon. Four or more is a code smell that suggests you should break the type into smaller pieces.Generic Factories
Factory constructors and static methods can use generics to create instances of different types based on the type parameter. This is useful for builder patterns and abstract factories.
Generic Factory Pattern
// A generic API response wrapper
class ApiResponse<T> {
final T? data;
final String? error;
final int statusCode;
final DateTime timestamp;
ApiResponse._({
this.data,
this.error,
required this.statusCode,
}) : timestamp = DateTime.now();
// Factory constructors
factory ApiResponse.success(T data, {int statusCode = 200}) =>
ApiResponse._(data: data, statusCode: statusCode);
factory ApiResponse.error(String message, {int statusCode = 500}) =>
ApiResponse._(error: message, statusCode: statusCode);
bool get isSuccess => error == null && data != null;
bool get isError => error != null;
// Transform the data type
ApiResponse<R> map<R>(R Function(T) transform) {
if (isSuccess) {
return ApiResponse.success(transform(data as T), statusCode: statusCode);
}
return ApiResponse.error(error!, statusCode: statusCode);
}
@override
String toString() => isSuccess
? 'ApiResponse(status: $statusCode, data: $data)'
: 'ApiResponse(status: $statusCode, error: $error)';
}
void main() {
// Success response with User data
var userResponse = ApiResponse.success(
{'id': 1, 'name': 'Alice'},
statusCode: 200,
);
print(userResponse);
// ApiResponse(status: 200, data: {id: 1, name: Alice})
// Error response
var errorResponse = ApiResponse<Map>.error(
'User not found',
statusCode: 404,
);
print(errorResponse);
// ApiResponse(status: 404, error: User not found)
// Transform: Map -> String (extract just the name)
var nameResponse = userResponse.map(
(data) => data['name'] as String,
);
print(nameResponse);
// ApiResponse(status: 200, data: Alice)
}
Practical Example: Generic API Service
Let’s combine everything into a realistic architecture you would use in a production Flutter app -- a generic API service with type-safe responses, caching, and error handling.
Real-World: Complete Generic API Architecture
// Base model interface
abstract class Identifiable {
String get id;
}
abstract class Serializable<T> {
Map<String, dynamic> toJson();
}
// Generic CRUD interface
abstract interface class CrudService<T extends Identifiable> {
Future<ApiResponse<T>> getById(String id);
Future<ApiResponse<List<T>>> getAll({int page, int perPage});
Future<ApiResponse<T>> create(T item);
Future<ApiResponse<T>> update(T item);
Future<ApiResponse<bool>> delete(String id);
}
// Generic implementation with caching
class CachedCrudService<T extends Identifiable> implements CrudService<T> {
final CrudService<T> _inner;
final Cache<String, T> _cache;
CachedCrudService(this._inner, this._cache);
@override
Future<ApiResponse<T>> getById(String id) async {
// Check cache first
var cached = _cache.get(id);
if (cached != null) {
return ApiResponse.success(cached);
}
// Fetch from inner service
var response = await _inner.getById(id);
if (response.isSuccess && response.data != null) {
_cache.set(id, response.data as T, ttl: Duration(minutes: 5));
}
return response;
}
@override
Future<ApiResponse<List<T>>> getAll({int page = 1, int perPage = 20}) =>
_inner.getAll(page: page, perPage: perPage);
@override
Future<ApiResponse<T>> create(T item) async {
var response = await _inner.create(item);
if (response.isSuccess) {
_cache.set(item.id, item, ttl: Duration(minutes: 5));
}
return response;
}
@override
Future<ApiResponse<T>> update(T item) async {
var response = await _inner.update(item);
if (response.isSuccess) {
_cache.set(item.id, item, ttl: Duration(minutes: 5));
}
return response;
}
@override
Future<ApiResponse<bool>> delete(String id) async {
var response = await _inner.delete(id);
if (response.isSuccess) {
_cache.remove(id);
}
return response;
}
}
// Concrete model
class Product implements Identifiable {
@override
final String id;
final String name;
final double price;
Product({required this.id, required this.name, required this.price});
@override
String toString() => 'Product($name, \$$price)';
}
// Usage shows how generics enable flexible composition
void main() {
// The same CachedCrudService works for Product, User, Order, etc.
// You write the caching logic ONCE and reuse it everywhere
print('Generic architecture: write once, use for any model type');
print('CrudService<Product> -- type-safe CRUD for products');
print('CrudService<User> -- same interface, different type');
print('CachedCrudService wraps ANY CrudService with caching');
}
CachedCrudService<T> that add behavior (caching) to any CRUD service without knowing the concrete type. You could also create LoggingCrudService<T>, RetryingCrudService<T>, or AuthenticatedCrudService<T> -- all reusable with any model type. This is the Decorator Pattern combined with generics.