Skip to content

Generic Classes

Understand how to create and use generic classes in Dart.


What is it?

Generic classes are classes that can work with different types while maintaining type safety. They allow you to write flexible, reusable code that works with any type, with compile-time type checking.


Why does it exist?

Generic classes exist to:

  • Write type-safe, reusable code
  • Avoid code duplication
  • Enable compile-time type checking
  • Create flexible APIs
  • Work with collections of any type
  • Provide better IDE support

Basic Generic Classes

Simple Generic Class

// Generic class with type parameter T
class Box<T> {
  T value;

  Box(this.value);

  T getValue() => value;

  void setValue(T newValue) {
    value = newValue;
  }

  @override
  String toString() => 'Box($value)';
}

// Usage with different types
var stringBox = Box<String>('Hello');
var intBox = Box<int>(42);
var doubleBox = Box<double>(3.14);

print(stringBox.getValue()); // Hello
print(intBox.getValue()); // 42
print(doubleBox.getValue()); // 3.14

// Type inference
var inferredBox = Box('Hello'); // Box<String>

Generic Class with Multiple Types

// Generic class with two type parameters
class Pair<K, V> {
  final K key;
  final V value;

  Pair(this.key, this.value);

  @override
  String toString() => 'Pair($key: $value)';
}

// Usage
var pair1 = Pair<String, int>('age', 25);
var pair2 = Pair<String, String>('name', 'Alice');
var pair3 = Pair<int, bool>(1, true);

print(pair1); // Pair(age: 25)
print(pair2); // Pair(name: Alice)
print(pair3); // Pair(1: true)

// Type inference
var inferredPair = Pair('score', 95); // Pair<String, int>

Generic Classes with Constraints

Type Bounds

// Generic with upper bound (must be Comparable)
class MaxFinder<T extends Comparable<T>> {
  T findMax(T a, T b) {
    return a.compareTo(b) > 0 ? a : b;
  }

  T findMaxInList(List<T> items) {
    if (items.isEmpty) {
      throw ArgumentError('List cannot be empty');
    }
    return items.reduce((a, b) => a.compareTo(b) > 0 ? a : b);
  }
}

// Usage
var intFinder = MaxFinder<int>();
print(intFinder.findMax(5, 3)); // 5
print(intFinder.findMaxInList([1, 5, 3, 9, 2])); // 9

var stringFinder = MaxFinder<String>();
print(stringFinder.findMax('apple', 'banana')); // banana

// Error: Cannot use non-Comparable type
// class NonComparable {}
// var badFinder = MaxFinder<NonComparable>(); // Error!

Multiple Constraints

// Generic with multiple constraints
class Repository<T extends Object> {
  final Map<String, T> _data = {};

  void save(String id, T item) {
    _data[id] = item;
  }

  T? find(String id) {
    return _data[id];
  }

  List<T> findAll() {
    return _data.values.toList();
  }

  void delete(String id) {
    _data.remove(id);
  }

  bool exists(String id) {
    return _data.containsKey(id);
  }
}

// Usage with any Object type
class User {
  final String name;
  User(this.name);

  @override
  String toString() => 'User($name)';
}

class Product {
  final String name;
  final double price;
  Product(this.name, this.price);

  @override
  String toString() => 'Product($name, $price)';
}

var userRepo = Repository<User>();
userRepo.save('1', User('Alice'));
userRepo.save('2', User('Bob'));

var productRepo = Repository<Product>();
productRepo.save('P1', Product('Laptop', 999.99));
productRepo.save('P2', Product('Phone', 599.99));

print(userRepo.findAll()); // [User(Alice), User(Bob)]
print(productRepo.findAll()); // [Product(Laptop, 999.99), Product(Phone, 599.99)]

Generic Collection Classes

Custom Generic Collection

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 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;

  void clear() {
    _items.clear();
  }

  @override
  String toString() => 'Stack($_items)';
}

// Usage
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
print(intStack.length); // 2

var stringStack = Stack<String>();
stringStack.push('Hello');
stringStack.push('World');
print(stringStack.pop()); // World

Generic Queue

class Queue<T> {
  final List<T> _items = [];

  void enqueue(T item) {
    _items.add(item);
  }

  T dequeue() {
    if (_items.isEmpty) {
      throw StateError('Queue is empty');
    }
    return _items.removeAt(0);
  }

  T peek() {
    if (_items.isEmpty) {
      throw StateError('Queue is empty');
    }
    return _items.first;
  }

  bool get isEmpty => _items.isEmpty;
  bool get isNotEmpty => _items.isNotEmpty;
  int get length => _items.length;

  @override
  String toString() => 'Queue($_items)';
}

// Usage
var queue = Queue<String>();
queue.enqueue('First');
queue.enqueue('Second');
queue.enqueue('Third');

print(queue.dequeue()); // First
print(queue.peek()); // Second
print(queue.length); // 2

Generic Class with Methods

Generic Methods in Generic Class

class CollectionUtils<T> {
  final List<T> _items = [];

  // Generic method with same type
  void add(T item) {
    _items.add(item);
  }

  void addAll(Iterable<T> items) {
    _items.addAll(items);
  }

  // Generic method with different type
  R map<R>(R Function(T) mapper) {
    return mapper(_items.first);
  }

  // Generic method returning new collection
  List<R> mapToList<R>(R Function(T) mapper) {
    return _items.map(mapper).toList();
  }

  // Method with type parameter
  List<U> filter<U extends T>(bool Function(T) predicate) {
    return _items.where(predicate).cast<U>().toList();
  }

  @override
  String toString() => 'CollectionUtils($_items)';
}

// Usage
var utils = CollectionUtils<String>();
utils.addAll(['apple', 'banana', 'cherry']);

var lengths = utils.mapToList((item) => item.length);
print(lengths); // [5, 6, 6]

var filtered = utils.filter((item) => item.startsWith('b'));
print(filtered); // [banana]

Best Practices

Use Descriptive Type Parameter Names

// Good: Descriptive names
class Repository<Entity> {
  // Entity is descriptive
}

class Box<Content> {
  // Content describes what's inside
}

// Good: Common conventions
class Result<T> {
  // T for generic type
}

class Pair<K, V> {
  // K for key, V for value
}

Use Constraints When Needed

// Good: Constraint for specific behavior
class Sorter<T extends Comparable<T>> {
  List<T> sort(List<T> items) {
    var sorted = List<T>.from(items);
    sorted.sort();
    return sorted;
  }
}

// Bad: No constraint when needed
class BadSorter<T> {
  List<T> sort(List<T> items) {
    // Can't call compareTo on T
    // items.sort(); // Error!
    return items;
  }
}

Common Mistakes

Using Dynamic Instead of Generic

Wrong:

class Box {
  dynamic value; // Loses type safety
  Box(this.value);
}

var box = Box('Hello');
var value = box.value; // Type is dynamic

Correct:

class Box<T> {
  T value; // Type safe
  Box(this.value);
}

var box = Box<String>('Hello');
var value = box.value; // Type is String

Wrong Type Arguments

Wrong:

class Box<T> {
  T value;
  Box(this.value);
}

var box = Box<int>('Hello'); // Error! String is not int

Correct:

class Box<T> {
  T value;
  Box(this.value);
}

var box = Box<String>('Hello'); // OK

Summary

Generic classes provide type-safe, reusable code. They allow you to write flexible classes that work with any type while maintaining compile-time type checking.


Next Steps

Now that you understand generic classes, continue to:


Did You Know?

  • Generic classes were introduced in Dart 2.0
  • Type parameters are resolved at compile time
  • Generics help catch errors early
  • Dart uses type erasure (generic types are not retained at runtime)
  • You can have multiple type parameters
  • Generics work with collections and classes
  • Type inference reduces boilerplate for generics