DEV Community

kanta13jp1
kanta13jp1

Posted on

Dart Generics Deep Dive — Bounds, Variance, and Production Patterns

Dart Generics Deep Dive — Bounds, Variance, and Production Patterns

Dart generics go far beyond "a class that takes a type parameter." Type bounds, covariance, reified generics, and multi-parameter types unlock compile-time correctness that runtime checks can't match. This post walks through patterns from real production code.

Basics: Type Parameters and Inference

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

  Box<R> map<R>(R Function(T) transform) => Box(transform(value));
}

// Inference works as expected
final intBox = Box(42);            // Box<int>
final strBox = Box('hello');       // Box<String>
final doubled = intBox.map((v) => v * 2);  // Box<int>
final asStr  = intBox.map((v) => '$v');    // Box<String>
Enter fullscreen mode Exit fullscreen mode

Type Bounds (extends)

// Accept only numeric types
class Calculable<T extends num> {
  final List<T> values;
  const Calculable(this.values);

  T get sum => values.reduce((a, b) => (a + b) as T);
  double get average => values.isEmpty ? 0 : sum / values.length;
  T get max => values.reduce((a, b) => a > b ? a : b);
}

// Accept only Comparable types
class SortedList<T extends Comparable<T>> {
  final List<T> _items;

  SortedList(List<T> items) : _items = List.from(items)..sort();

  T get min => _items.first;
  T get max => _items.last;

  List<T> range(T from, T to) =>
      _items.where((e) => e.compareTo(from) >= 0 && e.compareTo(to) <= 0).toList();
}

final nums = SortedList([5, 2, 8, 1, 9]);
print(nums.min);         // 1
print(nums.range(2, 7)); // [2, 5]

final strs = SortedList(['banana', 'apple', 'cherry']);
print(strs.min); // apple
Enter fullscreen mode Exit fullscreen mode

Multiple Type Parameters

// Either / Result type
sealed class Either<L, R> {
  const Either();

  bool get isLeft  => this is Left<L, R>;
  bool get isRight => this is Right<L, R>;

  T fold<T>(T Function(L) onLeft, T Function(R) onRight) {
    return switch (this) {
      Left(:final value)  => onLeft(value),
      Right(:final value) => onRight(value),
    };
  }

  Either<L, R2> mapRight<R2>(R2 Function(R) transform) {
    return switch (this) {
      Left()              => this as Either<L, R2>,
      Right(:final value) => Right(transform(value)),
    };
  }
}

class Left<L, R>  extends Either<L, R> { final L value; const Left(this.value);  }
class Right<L, R> extends Either<L, R> { final R value; const Right(this.value); }

// API call result
Future<Either<String, User>> fetchUser(String id) async {
  try {
    final data = await supabase.from('users').select().eq('id', id).single();
    return Right(User.fromJson(data));
  } catch (e) {
    return Left(e.toString());
  }
}

final result = await fetchUser('123');
final message = result.fold(
  (error) => 'Error: $error',
  (user)  => 'Welcome, ${user.name}!',
);
Enter fullscreen mode Exit fullscreen mode

The covariant Keyword

// Narrowing parameter types in subclasses
abstract class Repository<T> {
  Future<T?> findById(String id);
  Future<void> save(covariant T entity); // concrete subclasses accept only T, not its supertypes
  Future<List<T>> findAll();
}

class TaskRepository extends Repository<Task> {
  @override
  Future<Task?> findById(String id) async {
    final data = await supabase.from('tasks').select().eq('id', id).maybeSingle();
    return data == null ? null : Task.fromJson(data);
  }

  @override
  Future<void> save(Task entity) async {
    await supabase.from('tasks').upsert(entity.toJson());
  }

  @override
  Future<List<Task>> findAll() async {
    final data = await supabase.from('tasks').select();
    return data.map(Task.fromJson).toList();
  }
}
Enter fullscreen mode Exit fullscreen mode

Type Erasure? No — Dart Has Reified Generics

Unlike Java, Dart retains type information at runtime:

void checkType<T>() {
  print(T == int);    // true or false — works at runtime
  print(T == String);
}

// Practical: generic JSON deserialisation
class ApiClient {
  Future<T> get<T>(String path, T Function(Map<String, dynamic>) fromJson) async {
    final response = await http.get(Uri.parse('$baseUrl$path'));
    return fromJson(jsonDecode(response.body) as Map<String, dynamic>);
  }

  Future<List<T>> getList<T>(
    String path,
    T Function(Map<String, dynamic>) fromJson,
  ) async {
    final response = await http.get(Uri.parse('$baseUrl$path'));
    return (jsonDecode(response.body) as List)
        .cast<Map<String, dynamic>>()
        .map(fromJson)
        .toList();
  }
}

final client = ApiClient(baseUrl: 'https://api.example.com');
final user  = await client.get('/users/123', User.fromJson);
final tasks = await client.getList('/tasks', Task.fromJson);
Enter fullscreen mode Exit fullscreen mode

Type-Safe Service Locator

class ServiceLocator {
  final Map<Type, Object> _services = {};

  void register<T extends Object>(T service) => _services[T] = service;

  T get<T extends Object>() {
    final service = _services[T];
    if (service == null) throw StateError('$T not registered');
    return service as T;
  }
}

final locator = ServiceLocator()
  ..register<TaskRepository>(TaskRepository(supabase))
  ..register<UserRepository>(UserRepository(supabase));

final taskRepo = locator.get<TaskRepository>(); // fully type-safe
Enter fullscreen mode Exit fullscreen mode

Generic Widgets in Flutter

class TypedListView<T> extends StatelessWidget {
  const TypedListView({
    super.key,
    required this.items,
    required this.itemBuilder,
    this.emptyWidget = const SizedBox.shrink(),
  });

  final List<T> items;
  final Widget Function(BuildContext, T, int) itemBuilder;
  final Widget emptyWidget;

  @override
  Widget build(BuildContext context) {
    if (items.isEmpty) return emptyWidget;
    return ListView.builder(
      itemCount: items.length,
      itemBuilder: (ctx, i) => itemBuilder(ctx, items[i], i),
    );
  }
}

// Usage
TypedListView<Task>(
  items: tasks,
  itemBuilder: (ctx, task, i) => TaskTile(task: task),
  emptyWidget: const Center(child: Text('No tasks')),
)
Enter fullscreen mode Exit fullscreen mode

When to Use What

Feature Use When
<T> Collections, repositories, widgets
<T extends X> Numeric ops, Comparable, interface contracts
<L, R> (multi-param) Either/Result types, transformation pipelines
covariant Narrowing param types in subclasses
Reified generics Runtime type dispatch, factory patterns

Master Dart generics and you eliminate the "same logic repeated for every type" problem while letting the type system catch whole categories of bugs at compile time.


Building a type-safe life-management app with Dart + Flutter. Follow the journey → @kanta13jp1

Top comments (0)