Skip to content

Generic Methods

Understand how to define and use generic methods in Dart classes and interfaces.


What is it?

Generic methods are methods that can work with different types while maintaining type safety. Unlike generic classes that are parameterized at the class level, generic methods introduce type parameters at the method level, allowing for more flexible and reusable code.


Why does it exist?

Generic methods exist to:

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

Generic Methods in Classes

Basic Generic Methods

class CollectionUtils {
  // Generic method in non-generic class
  T identity<T>(T value) {
    return value;
  }

  List<R> mapList<T, R>(List<T> items, R Function(T) mapper) {
    return items.map(mapper).toList();
  }

  T? findFirst<T>(List<T> items, bool Function(T) predicate) {
    for (var item in items) {
      if (predicate(item)) {
        return item;
      }
    }
    return null;
  }
}

// Usage
var utils = CollectionUtils();
var numbers = [1, 2, 3, 4, 5];

// Identity
print(utils.identity('Hello')); // Hello
print(utils.identity(42)); // 42

// Map
var doubled = utils.mapList(numbers, (n) => n * 2);
print(doubled); // [2, 4, 6, 8, 10]

// Find
var firstEven = utils.findFirst(numbers, (n) => n % 2 == 0);
print(firstEven); // 2

Generic Methods in Generic Classes

class Repository<T> {
  final Map<String, T> _data = {};

  // Generic method with different type
  R process<R>(T item, R Function(T) processor) {
    return processor(item);
  }

  // Generic method returning different type
  List<R> mapTo<R>(R Function(T) mapper) {
    return _data.values.map(mapper).toList();
  }

  // Generic method with type parameter
  U? findAndTransform<U>(String id, U Function(T) transformer) {
    var item = _data[id];
    if (item != null) {
      return transformer(item);
    }
    return null;
  }

  // Method with generic constraint
  U findMax<U extends Comparable<U>>(U Function(T) extractor) {
    if (_data.isEmpty) {
      throw StateError('Repository is empty');
    }
    return _data.values
        .map(extractor)
        .reduce((a, b) => a.compareTo(b) > 0 ? a : b);
  }
}

// Usage
class User {
  final String name;
  final int age;

  User(this.name, this.age);
}

var repo = Repository<User>();
repo.save('1', User('Alice', 25));
repo.save('2', User('Bob', 30));
repo.save('3', User('Charlie', 20));

// Process method
var info = repo.process(
  User('Alice', 25),
  (user) => '${user.name} is ${user.age}',
);
print(info); // Alice is 25

// Map to method
var names = repo.mapTo((user) => user.name);
print(names); // [Alice, Bob, Charlie]

// Find and transform
var age = repo.findAndTransform('2', (user) => user.age);
print(age); // 30

// Find max with constraint
var maxAge = repo.findMax((user) => user.age);
print(maxAge); // 30

Generic Methods with Constraints

Bounded Generic Methods

class Utilities {
  // Generic method with upper bound
  T maxValue<T extends Comparable<T>>(T a, T b) {
    return a.compareTo(b) > 0 ? a : b;
  }

  // Generic method with multiple constraints
  T process<T extends Object & Comparable<T>>(List<T> items) {
    if (items.isEmpty) {
      throw ArgumentError('List cannot be empty');
    }
    return items.reduce((a, b) => a.compareTo(b) > 0 ? a : b);
  }

  // Generic method with type bound
  List<T> sortList<T extends Comparable<T>>(List<T> items) {
    var sorted = List<T>.from(items);
    sorted.sort();
    return sorted;
  }
}

// Usage
var utils = Utilities();
print(utils.maxValue(5, 3)); // 5
print(utils.maxValue('apple', 'banana')); // banana

var numbers = [3, 1, 4, 1, 5, 9, 2];
print(utils.sortList(numbers)); // [1, 1, 2, 3, 4, 5, 9]
print(utils.process(numbers)); // 9

// Error: Cannot use non-Comparable
// class NonComparable {}
// utils.maxValue(NonComparable(), NonComparable()); // Error!

Generic Methods in Interfaces

Interface with Generic Methods

// Interface with generic methods
abstract class DataTransformer {
  // Generic method in interface
  T transform<T>(T input);

  // Generic method with constraints
  R process<T, R>(T input, R Function(T) processor);

  // Generic method with different return type
  List<R> transformList<T, R>(List<T> items, R Function(T) mapper);
}

class StringTransformer implements DataTransformer {
  @override
  T transform<T>(T input) {
    // Transform based on type
    if (input is String) {
      return (input as String).toUpperCase() as T;
    } else if (input is int) {
      return (input * 2) as T;
    }
    return input;
  }

  @override
  R process<T, R>(T input, R Function(T) processor) {
    return processor(input);
  }

  @override
  List<R> transformList<T, R>(List<T> items, R Function(T) mapper) {
    return items.map(mapper).toList();
  }
}

// Usage
var transformer = StringTransformer();

// Transform method
print(transformer.transform<String>('hello')); // HELLO
print(transformer.transform<int>(5)); // 10

// Process method
var result = transformer.process<String, int>(
  '123',
  (s) => int.parse(s),
);
print(result); // 123

// Transform list
var numbers = [1, 2, 3];
var doubled = transformer.transformList<int, int>(
  numbers,
  (n) => n * 2,
);
print(doubled); // [2, 4, 6]

Generic Methods with Type Inference

Leveraging Inference

class Processor {
  // Methods where types can be inferred
  R processValue<T, R>(T value, R Function(T) processor) {
    return processor(value);
  }

  // Multiple parameters with different types
  R combine<T, U, R>(
    T first,
    U second,
    R Function(T, U) combiner,
  ) {
    return combiner(first, second);
  }

  // Nested generic types
  List<R> processNested<T, R>(
    List<List<T>> nested,
    R Function(List<T>) processor,
  ) {
    return nested.map(processor).toList();
  }
}

// Usage
var processor = Processor();

// Type inference works
var length = processor.processValue(
  'Hello',
  (s) => s.length,
);
print(length); // 5

// Multiple types
var combined = processor.combine<String, int, String>(
  'Age: ',
  25,
  (s, n) => '$s$n',
);
print(combined); // Age: 25

// Nested processing
var matrix = [
  [1, 2, 3],
  [4, 5, 6],
  [7, 8, 9],
];
var sums = processor.processNested(
  matrix,
  (row) => row.reduce((a, b) => a + b),
);
print(sums); // [6, 15, 24]

Generic Methods in Practice

Real-World Examples

// 1. JSON serialization helper
class JsonHelper {
  static T fromJson<T>(Map<String, dynamic> json, T Function(Map<String, dynamic>) parser) {
    return parser(json);
  }

  static List<T> fromJsonList<T>(
    List<Map<String, dynamic>> jsonList,
    T Function(Map<String, dynamic>) parser,
  ) {
    return jsonList.map(parser).toList();
  }

  static Map<String, dynamic> toJson<T>(
    T object,
    Map<String, dynamic> Function(T) serializer,
  ) {
    return serializer(object);
  }
}

// 2. Validation helper
class Validator {
  static bool validate<T>(
    T value,
    bool Function(T) rule,
  ) {
    return rule(value);
  }

  static List<T> filterValid<T>(
    List<T> items,
    bool Function(T) rule,
  ) {
    return items.where(rule).toList();
  }

  static bool validateAll<T>(
    List<T> items,
    bool Function(T) rule,
  ) {
    return items.every(rule);
  }
}

// 3. Cache helper
class Cache<T> {
  final Map<String, T> _cache = {};
  final Duration _ttl;

  Cache({Duration ttl = const Duration(minutes: 5)}) : _ttl = ttl;

  void put(String key, T value) {
    _cache[key] = value;
  }

  T? get(String key) {
    return _cache[key];
  }

  // Generic method for transforming cache
  U getOrCompute<U>(
    String key,
    U Function(T?) compute,
  ) {
    var cached = _cache[key] as T?;
    var result = compute(cached);
    if (result != null && result is T) {
      _cache[key] = result;
    }
    return result;
  }
}

// Usage
// 1. JSON
var json = {'name': 'Alice', 'age': 25};
var user = JsonHelper.fromJson(json, (j) => User(j['name'] as String, j['age'] as int));

// 2. Validation
var isValid = Validator.validate('test@example.com', (s) => s.contains('@'));
var filtered = Validator.filterValid([1, 2, 3, 4, 5], (n) => n % 2 == 0);

// 3. Cache
var cache = Cache<String>();
cache.put('key', 'value');
var cachedValue = cache.get('key');

Generic Method Best Practices

Type Safety

// Good: Type-safe generic method
List<R> safeTransform<T, R>(
  List<T> items,
  R Function(T) transformer,
) {
  return items.map(transformer).toList();
}

// Bad: Unnecessary dynamic
List<dynamic> unsafeTransform(List items, Function transformer) {
  return items.map(transformer).toList(); // Loses type safety
}

Appropriate Use

// Good: Useful generic method
class StringUtils {
  static T parse<T>(String value, T Function(String) parser) {
    return parser(value);
  }
}

// Bad: Overly generic
class Utils {
  static T identity<T>(T value) {
    return value;
  }
}

Common Mistakes

Missing Type Information

Wrong:

void processItems(List items) {
  // No type information
  for (var item in items) {
    print(item);
  }
}

Correct:

void processItems<T>(List<T> items) {
  // Type information preserved
  for (var item in items) {
    print(item);
  }
}

Unnecessary Generic Methods

Wrong:

class Utils {
  T wrap<T>(T value) {
    return value; // Unnecessary - just returns the value
  }
}

Correct:

class Utils {
  // Only generic when actually needed
  List<R> transform<T, R>(List<T> items, R Function(T) transformer) {
    return items.map(transformer).toList();
  }
}

Summary

Generic methods provide type-safe, reusable functionality at the method level. They allow for flexible APIs while maintaining compile-time type safety.


Next Steps

Now that you understand generic methods, continue to:


Did You Know?

  • Generic methods were introduced in Dart 2.0
  • Type parameters are resolved at compile time
  • Methods can have different type parameters than their class
  • Generic methods enable functional programming patterns
  • Type inference works with generic methods
  • Generic methods can have constraints
  • Generic methods are used throughout the Dart SDK