Dart Object-Oriented Programming

Generics Fundamentals

50 min Lesson 10 of 24

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
}
Key Concept: The letter 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
}
Warning: Without a type constraint, 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');
  }
}
Best Practice: The 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
}
Why This Matters: The generic repository pattern eliminates code duplication across your entire data layer. Without generics, you would need separate 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.