Watch the video (12 min)
On this page25 sections
  1. Prerequisites
  2. Connection With Previous Lectures
  3. Learning Objectives
  4. 1. Writing a Generic Class
  5. 2. Dart Checks Every T
  6. 3. Inference and Explicit Type Arguments
  7. 4. Several Type Parameters
  8. 5. Naming Type Parameters
  9. 6. A Generic Stack<E>
  10. 7. A Result<T> Holder
  11. 8. Generic Functions
  12. 9. A Generic swap
  13. 10. Generic Methods Inside a Class
  14. 11. When Inference Guesses Too Wide
  15. 12. Generic Typedefs Revisited
  16. 13. A Real-World Example
  17. Code Examples
  18. Common Beginner Mistakes
  19. Best Practices
  20. Mini Challenge
  21. Practice Questions
  22. Practice Answers
  23. Quick Revision
  24. Key Takeaways
  25. What's Next?

Lecture 73 — Generic Classes & Functions

Category12 — Generics & Advanced Types
Lecture073 of 140
Estimated video length12 min
TargetDart 3.12.2
LevelIntermediate

So far, List<T> and Map<K, V> came ready-made from Dart. Now you will write your own generic classes and functions: one Stack<T>, one firstOrDefault<T>, safe for every type.

Contents

Prerequisites · Connection · Objectives · Lesson · Code Examples · Common Mistakes · Best Practices · Mini Challenge · Practice Questions · Answers · Takeaways

Every error message in this lecture is real. Each one was produced by running the code shown on Dart 3.12.2, so you will recognize it when you meet it yourself.


Prerequisites

Students should already understand, from Lectures 011, 035, 053–055, 071 and 072:

  • why Object and dynamic lose type information
  • classes, fields, constructors and named constructors
  • function types and typedefs, including typedef Check<T> = bool Function(T);
  • what a type parameter is, and why List<int> and List<String> are different types

Connection With Previous Lectures

In Lecture 072 every generic type we used was written by someone else. Suppose Sara wants a "last in, first out" pile for her shop's undo button, and Ali wants the same pile for student names. Without generics, she writes two classes:

dart
class IntStack {
  final _items = <int>[];
  void push(int value) => _items.add(value);
}

class StringStack {
  final _items = <String>[];
  void push(String value) => _items.add(value);
}

The code is identical except for one word. A third type means a third copy, and a dynamic stack would lose type safety (Lecture 011). We need one class with a blank for the type.


Learning Objectives

By the end of this lecture you will be able to:

  • Write a generic class with a type parameter, like Box<T>
  • Use several type parameters in one class, like Pair<A, B>
  • Build a practical generic class: a Stack<T> and a Result<T>
  • Write generic functions such as firstOrDefault<T> and swap<T>
  • Add a generic method with its own type parameter inside a class
  • Choose conventional names: T, E, K, V, R
  • Combine generic typedefs with your own generic types
  • Decide when to rely on inference and when to write type arguments explicitly

1. Writing a Generic Class

Put the type parameter in angle brackets after the class name, then use it like any type inside the class:

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

void main() {
  Box<int> marks = Box<int>(85);
  Box<String> name = Box<String>('Ali');
  print(marks.value + 5);
  print(name.value.toUpperCase());
}
output
90
ALI

Inside Box<int>, every T becomes int, so marks.value + 5 is allowed. Inside Box<String>, T is String, so toUpperCase() works. One class, fully typed for each use.


2. Dart Checks Every T

Once T is chosen, Dart holds you to it:

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

void main() {
  var marks = Box<int>(85);
  marks.value = 90;
  // marks.value = 'ninety'; // ✗
  print(marks.value);
}
output
90

Uncommenting the last assignment gives:

output
Error: A value of type 'String' can't be assigned to a variable of type 'int'.
  marks.value = 'ninety';
                ^

Notice the message says int, not T: Dart has already filled in the blank.


3. Inference and Explicit Type Arguments

You rarely need to write Box<int>(85). Dart reads the argument and infers T:

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

void main() {
  var price = Box(1500);
  var city = Box('Lahore');
  print(price.runtimeType);
  print(city.runtimeType);
}
output
Box<int>
Box<String>

Write the type argument yourself when there is nothing to infer from — for example, an empty collection — or when you want Dart to check your intent. We will see both cases in sections 6 and 11.


4. Several Type Parameters

Separate type parameters with commas. Each one is independent:

dart
class Pair<A, B> {
  final A first;
  final B second;
  Pair(this.first, this.second);

  @override
  String toString() => '($first, $second)';
}

void main() {
  var student = Pair('Sara', 92);
  var item = Pair('Pen', 50.5);
  print(student);
  print(item.second + 10);
}
output
(Sara, 92)
60.5

student is a Pair<String, int> and item a Pair<String, double>. Dart also has a built-in way to group values without writing a class — records, in Lecture 076.


5. Naming Type Parameters

A type parameter can have any name, but Dart code follows a short convention:

LetterMeaningExample
Tany TypeBox<T>
Ean Element of a collectionList<E>, Stack<E>
K, Va map's Key and ValueMap<K, V>
Ra Return or result typeR Function(T)
A, Bseveral unrelated typesPair<A, B>

Single capital letters make a type parameter easy to tell apart from a real class name like Student.


6. A Generic Stack<E>

Now Sara's pile, written once. pop() returns E? because an empty stack has nothing to give back:

dart
class Stack<E> {
  final _items = <E>[];

  void push(E item) => _items.add(item);
  E? pop() => _items.isEmpty ? null : _items.removeLast();
  bool get isEmpty => _items.isEmpty;
}

void main() {
  var undo = Stack<String>();
  undo.push('add Pen');
  undo.push('add Bag');
  print(undo.pop());
  print(undo.pop());
  print(undo.pop());
}
output
add Bag
add Pen
null

<E>[] creates the inner list with the same element type as the stack. Stack<int>, Stack<double>, or a stack of students all come from this one class.


7. A Result<T> Holder

A generic class can also describe success or failure for any kind of value. Named constructors (Lecture 055) make the two cases clear:

dart
class Result<T> {
  final T? value;
  final String? error;

  Result.ok(T this.value) : error = null;
  Result.fail(String this.error) : value = null;

  bool get isOk => error == null;
}

Result<int> parseMarks(String text) {
  var marks = int.tryParse(text);
  return marks == null
      ? Result.fail('"$text" is not a number')
      : Result.ok(marks);
}

void main() {
  for (var input in ['85', 'abc']) {
    var result = parseMarks(input);
    print(result.isOk ? 'Marks: ${result.value}' : 'Error: ${result.error}');
  }
}
output
Marks: 85
Error: "abc" is not a number

Inside parseMarks, the return type Result<int> tells Dart what T is for both constructors. Later lectures give failure its own tools: sealed classes in Lecture 082 and exceptions in Lectures 083–086.


8. Generic Functions

A function gets its type parameter right after its name. Here, T links the list, the fallback and the return value:

dart
T firstOrDefault<T>(List<T> items, T fallback) {
  return items.isEmpty ? fallback : items[0];
}

void main() {
  print(firstOrDefault([80, 75, 90], 0));
  print(firstOrDefault(<String>[], 'nobody'));
}
output
80
nobody

In the first call T is int; in the second it is String. The result has that exact type, so firstOrDefault([80, 75], 0) + 5 would compile with no cast.


9. A Generic swap

A generic function can change its arguments as well as return values. This one swaps two positions in a list of any type:

dart
void swap<T>(List<T> items, int i, int j) {
  var temp = items[i];
  items[i] = items[j];
  items[j] = temp;
}

void main() {
  var names = ['Ali', 'Sara', 'Zayan'];
  var temps = [31.5, 28.0];
  swap(names, 0, 2);
  swap(temps, 0, 1);
  print(names);
  print(temps);
}
output
[Zayan, Sara, Ali]
[28.0, 31.5]

temp is inferred as T, so only values of the list's own type can move around.


10. Generic Methods Inside a Class

A method can declare its own type parameter, separate from the class's. Here Box<T> gets a map<R> method that turns its value into a different type:

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

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

void main() {
  var marks = Box(72);
  var grade = marks.map((m) => m >= 50 ? 'Pass' : 'Fail');
  print(grade.value);
  print(grade.runtimeType);
}
output
Pass
Box<String>

T belongs to the box (int); R belongs to this one call (String). This is how List.map from Lecture 043 works too.


11. When Inference Guesses Too Wide

Inference picks a type that fits all arguments. If they disagree, it falls back to a shared type such as Object, with no error:

dart
T firstOrDefault<T>(List<T> items, T fallback) {
  return items.isEmpty ? fallback : items[0];
}

void main() {
  var name = firstOrDefault(['Ali'], 0); // T is Object
  print(name);
  // print(name.length); // ✗
  var safe = firstOrDefault<String>(['Ali'], 'nobody');
  print(safe.length);
}
output
Ali
3

Uncommenting name.length gives:

output
Error: The getter 'length' isn't defined for the type 'Object'.

Writing <String> states your intent, so a wrong fallback like 0 becomes an error right at the call.


12. Generic Typedefs Revisited

Lecture 071 previewed typedef Check<T> = bool Function(T);. Typedefs can also take several type parameters, or name a generic class with its blanks filled in:

dart
class Pair<A, B> {
  final A first;
  final B second;
  Pair(this.first, this.second);
}

typedef Converter<T, R> = R Function(T);
typedef NamedMarks = Pair<String, int>;

void main() {
  Converter<double, double> toF = (c) => c * 9 / 5 + 32;
  NamedMarks top = Pair('Sara', 92);
  print(toF(30));
  print('${top.first}: ${top.second}');
}
output
86.0
Sara: 92

NamedMarks is just another name for Pair<String, int>, exactly like the aliases from Lecture 071.


13. A Real-World Example

dart
class Stack<E> {
  final _items = <E>[];
  void push(E item) => _items.add(item);
  E? pop() => _items.isEmpty ? null : _items.removeLast();
}

class Pair<A, B> {
  final A first;
  final B second;
  Pair(this.first, this.second);
}

T firstOrDefault<T>(List<T> items, T fallback) =>
    items.isEmpty ? fallback : items[0];

void main() {
  var cart = Stack<Pair<String, int>>();
  cart.push(Pair('Notebook', 200));
  cart.push(Pair('Bag', 1500));

  var removed = cart.pop();
  print('Undo: ${removed?.first}');

  var discounts = <int>[];
  var discount = firstOrDefault(discounts, 0);
  var last = cart.pop();
  print('Pay: ${last!.second - discount}');
}
output
Undo: Bag
Pay: 200

The stack holds Pair<String, int> values, so last!.second is an int — no casts anywhere, and three generic pieces fit together.


Code Examples

Example 1 — A Box<T>

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

void main() {
  var age = Box(20);
  var city = Box('Karachi');
  print('${age.value} ${city.value}');
}
output
20 Karachi

Example 2 — Explicit Type Argument

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

void main() {
  var price = Box<double>(99);
  print(price.value);
}
output
99.0

How it works

  1. Box<double> fixes T as double before the argument is read.
  2. 99 is then treated as a double; Box(99) would have made a Box<int>.

Example 3 — A Pair<A, B>

dart
class Pair<A, B> {
  final A first;
  final B second;
  Pair(this.first, this.second);
}

void main() {
  var p = Pair('Zayan', true);
  print('${p.first} present: ${p.second}');
}
output
Zayan present: true

Example 4 — A Generic Function

dart
T lastOrDefault<T>(List<T> items, T fallback) {
  return items.isEmpty ? fallback : items[items.length - 1];
}

void main() {
  print(lastOrDefault(['Ali', 'Sara'], 'none'));
  print(lastOrDefault(<int>[], -1));
}
output
Sara
-1

Example 5 — swap on Integers

dart
void swap<T>(List<T> items, int i, int j) {
  var temp = items[i];
  items[i] = items[j];
  items[j] = temp;
}

void main() {
  var marks = [40, 90, 65];
  swap(marks, 0, 1);
  print(marks);
}
output
[90, 40, 65]

Example 6 — A Generic Method

dart
class Pair<A, B> {
  final A first;
  final B second;
  Pair(this.first, this.second);

  Pair<B, A> swapped() => Pair(second, first);
}

void main() {
  var p = Pair('Pen', 50).swapped();
  print('${p.first} ${p.second}');
  print(p.runtimeType);
}
output
50 Pen
Pair<int, String>

How it works

  1. swapped() returns Pair<B, A> — the same two type parameters in reverse order.
  2. Pair<String, int> becomes Pair<int, String>.

Example 7 — Student Marks Stack

dart
class Stack<E> {
  final _items = <E>[];
  void push(E item) => _items.add(item);
  E? pop() => _items.isEmpty ? null : _items.removeLast();
}

void main() {
  var marks = Stack<int>();
  for (var m in [80, 75, 85]) {
    marks.push(m);
  }
  var total = 0;
  int? m;
  while ((m = marks.pop()) != null) {
    total += m!;
  }
  print('Total: $total');
}
output
Total: 240

Example 8 — Shopping Prices with firstOrDefault

dart
T firstOrDefault<T>(List<T> items, T fallback) =>
    items.isEmpty ? fallback : items[0];

void main() {
  var cart = [1500, 200, 50];
  var emptyCart = <int>[];
  print('First item: ${firstOrDefault(cart, 0)}');
  print('First item: ${firstOrDefault(emptyCart, 0)}');
}
output
First item: 1500
First item: 0

Example 9 — Bank Withdrawal with Result<T>

dart
class Result<T> {
  final T? value;
  final String? error;
  Result.ok(T this.value) : error = null;
  Result.fail(String this.error) : value = null;
}

Result<double> withdraw(double balance, double amount) {
  if (amount > balance) return Result.fail('Insufficient funds');
  return Result.ok(balance - amount);
}

void main() {
  var a = withdraw(1000, 300);
  var b = withdraw(1000, 5000);
  print(a.value ?? a.error);
  print(b.value ?? b.error);
}
output
700.0
Insufficient funds

How it works

  1. The return type Result<double> decides T for both constructors.
  2. A successful result has a value and no error; a failed one the opposite, so ?? picks whichever is set.

Example 10 — Temperature Conversion with map<R>

dart
class Box<T> {
  final T value;
  Box(this.value);
  Box<R> map<R>(R Function(T) convert) => Box(convert(value));
}

void main() {
  var celsius = Box(25.0);
  var fahrenheit = celsius.map((c) => c * 9 / 5 + 32);
  var label = fahrenheit.map((f) => f > 86 ? 'hot' : 'pleasant');
  print('${fahrenheit.value} ${label.value}');
}
output
77.0 pleasant

Common Beginner Mistakes

1. Using a type parameter that was never declared

dart
// E pick(List<E> items) => items[0]; // ✗ missing <E>
E pick<E>(List<E> items) => items[0];
output
Error: Type 'E' not found.

A function's type parameter must be declared after its name: pick<E>.

2. Using T in a static member

dart
class Box<T> {
  T value;
  Box(this.value);
  // static T? last; // ✗
}
output
Error: Type variables can't be used in static members.

Static members belong to the class itself, not to a Box<int> or a Box<String>, so there is no T for them.

3. Creating an empty generic object without a type argument

dart
class Stack<E> {
  final _items = <E>[];
  void push(E item) => _items.add(item);
}

void main() {
  var mixed = Stack(); // ✗ nothing to infer from
  mixed.push(1);
  mixed.push('two'); // no error
  print(mixed.runtimeType);
}
output
Stack<dynamic>

With no argument to read, E becomes dynamic and type checking is gone. Write Stack<int>(). The same happens with Result.fail('...') outside a typed context: it gives a Result<dynamic>.

4. Calling type-specific methods on T

dart
void show<T>(T value) {
  // print(value.length); // ✗
}
output
Error: The getter 'length' isn't defined for the type 'Object?'.

T could be any type, even int, so Dart only allows what every value supports. Limiting T to types that have length is the subject of Lecture 074.

5. Mismatched explicit type arguments

dart
T firstOrDefault<T>(List<T> items, T fallback) =>
    items.isEmpty ? fallback : items[0];

void main() {
  // print(firstOrDefault<int>(['a'], 0)); // ✗
}
output
Error: A value of type 'String' can't be assigned to a variable of type 'int'.

This error is useful: the explicit <int> caught a list of the wrong type.


Best Practices

  • Make a class or function generic when its code would be identical for several types.
  • Use the conventional names: T, E for elements, K/V for keys and values, R for results.
  • Let inference fill type arguments when arguments make the type obvious.
  • Write type arguments explicitly for empty objects (Stack<int>(), <String>[]) and when arguments could disagree.
  • Return a nullable T? when a generic method may have nothing to give back.
  • Give a generic method its own type parameter (map<R>) when its result type differs from the class's.

Mini Challenge

Write a generic class Counter<T> that counts how many times each value was added, using a private Map<T, int>. Give it void add(T item) and int countOf(T item). Count Ali's votes and Sara's votes from ['Ali', 'Sara', 'Ali'].

Your program should print exactly:

output
Ali: 2
Sara: 1

Try it yourself before opening the solution.

Show solution
dart
class Counter<T> {
  final _counts = <T, int>{};

  void add(T item) => _counts[item] = countOf(item) + 1;
  int countOf(T item) => _counts[item] ?? 0;
}

void main() {
  var votes = Counter<String>();
  for (var name in ['Ali', 'Sara', 'Ali']) {
    votes.add(name);
  }
  print('Ali: ${votes.countOf('Ali')}');
  print('Sara: ${votes.countOf('Sara')}');
}
output
Ali: 2
Sara: 1

How it works

  1. <T, int>{} creates a map whose keys have the counter's type.
  2. countOf returns 0 for a value not seen yet, so add can always add 1.

Practice Questions

Work through these in order — they run from beginner to application to challenge. Every question gives you exact values, so you can compare your output with the answers.

Beginner

  1. Write class Box<T> with one field value. Create a box holding 'Sara' and print its value in upper case.
  2. Write class Pair<A, B>. Create a pair of 'Bag' and 1500 and print Bag costs 1500.
  3. What does print(Box(3.5).runtimeType); print? Write the program to check.

Application

  1. Write a generic function T middle<T>(List<T> items) that returns the middle element. Print it for [10, 20, 30] and ['Ali', 'Sara', 'Zayan'].
  2. Add an E? peek() method to Stack<E> that returns the top item without removing it. Push 5 and 9, then print peek() twice.
  3. Use swap from section 9 to turn ['Zayan', 'Ali'] into ['Ali', 'Zayan'].
  4. Write Result<int> safeDivide(int a, int b) that fails with 'Cannot divide by zero' when b is 0 and otherwise returns a ~/ b. Print the results for 10, 2 and 10, 0.

Challenge

  1. Write a generic function List<R> convertAll<T, R>(List<T> items, R Function(T) f) without using map. Convert [25.0, 30.0] from Celsius to Fahrenheit.
  2. Write typedef Score = Pair<String, int>; and a function Score best(List<Score> scores) that returns the highest. Print it for Ali 80, Sara 92, Zayan 67.
  3. firstOrDefault(['Ali'], 0) compiles. Explain in one sentence what type T gets, and rewrite the call so Dart rejects the 0.

Practice Answers

Question 1 — Solution

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

void main() {
  var name = Box('Sara');
  print(name.value.toUpperCase());
}
output
SARA

Question 2 — Solution

dart
class Pair<A, B> {
  final A first;
  final B second;
  Pair(this.first, this.second);
}

void main() {
  var item = Pair('Bag', 1500);
  print('${item.first} costs ${item.second}');
}
output
Bag costs 1500

Question 3 — Solution

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

void main() {
  print(Box(3.5).runtimeType);
}
output
Box<double>

Question 4 — Solution

dart
T middle<T>(List<T> items) => items[items.length ~/ 2];

void main() {
  print(middle([10, 20, 30]));
  print(middle(['Ali', 'Sara', 'Zayan']));
}
output
20
Sara

Question 5 — Solution

dart
class Stack<E> {
  final _items = <E>[];
  void push(E item) => _items.add(item);
  E? pop() => _items.isEmpty ? null : _items.removeLast();
  E? peek() => _items.isEmpty ? null : _items.last;
}

void main() {
  var s = Stack<int>();
  s.push(5);
  s.push(9);
  print(s.peek());
  print(s.peek());
}
output
9
9

Question 6 — Solution

dart
void swap<T>(List<T> items, int i, int j) {
  var temp = items[i];
  items[i] = items[j];
  items[j] = temp;
}

void main() {
  var names = ['Zayan', 'Ali'];
  swap(names, 0, 1);
  print(names);
}
output
[Ali, Zayan]

Question 7 — Solution

dart
class Result<T> {
  final T? value;
  final String? error;
  Result.ok(T this.value) : error = null;
  Result.fail(String this.error) : value = null;
}

Result<int> safeDivide(int a, int b) {
  if (b == 0) return Result.fail('Cannot divide by zero');
  return Result.ok(a ~/ b);
}

void main() {
  for (var r in [safeDivide(10, 2), safeDivide(10, 0)]) {
    print(r.value ?? r.error);
  }
}
output
5
Cannot divide by zero

Question 8 — Solution

dart
List<R> convertAll<T, R>(List<T> items, R Function(T) f) {
  var result = <R>[];
  for (var item in items) {
    result.add(f(item));
  }
  return result;
}

void main() {
  print(convertAll([25.0, 30.0], (c) => c * 9 / 5 + 32));
}
output
[77.0, 86.0]

Question 9 — Solution

dart
class Pair<A, B> {
  final A first;
  final B second;
  Pair(this.first, this.second);
}

typedef Score = Pair<String, int>;

Score best(List<Score> scores) {
  var top = scores[0];
  for (var s in scores) {
    if (s.second > top.second) top = s;
  }
  return top;
}

void main() {
  var top = best([Pair('Ali', 80), Pair('Sara', 92), Pair('Zayan', 67)]);
  print('${top.first}: ${top.second}');
}
output
Sara: 92

Question 10 — Solution

T becomes Object, the closest type shared by String and int. Writing the type argument makes the mistake an error:

dart
T firstOrDefault<T>(List<T> items, T fallback) =>
    items.isEmpty ? fallback : items[0];

void main() {
  // print(firstOrDefault<String>(['Ali'], 0)); // ✗
  print(firstOrDefault<String>(['Ali'], 'nobody'));
}
output
Ali

The commented-out call gives Error: The argument type 'int' can't be assigned to the parameter type 'String'.


Quick Revision

  1. Where do you write the type parameter of a generic class? Of a generic function?
  2. How many type parameters can a class have?
  3. What does each letter usually mean: T, E, K, V, R?
  4. Why does pop() in Stack<E> return E??
  5. What does Stack() with no type argument become?
  6. How is map<R> in Box<T> different from the class's T?
  7. Why can't a static field use T?
  8. Why can't you call .length on a value of type T?

Key Takeaways

  • class Box<T> declares a type parameter; every T inside is replaced when you write Box<int> or Box<String>.
  • A class may have several type parameters: Pair<A, B>.
  • Generic functions declare their type parameter after the name: T firstOrDefault<T>(...).
  • A method can have its own type parameter, like Box<R> map<R>(...).
  • Use T, E, K, V, R by convention.
  • Dart infers type arguments from arguments and context; empty objects fall back to dynamic, and disagreeing arguments to Object.
  • Write type arguments explicitly when there is nothing to infer from or to check intent.
  • Generic typedefs name function types (Converter<T, R>) or filled-in classes (NamedMarks).

What's Next?

Lecture 74 — Generic Constraints. Our T can be anything, which is exactly why value.length and a > b are rejected inside generic code. Next we will learn how extends limits a type parameter — for example to numbers only — so generic code can use the methods those types share.


Video lecture

Subscribe
Placeholder video — the real recording isn't linked yet.