On this page25 sections
- Prerequisites
- Connection With Previous Lectures
- Learning Objectives
- 1. Writing a Generic Class
- 2. Dart Checks Every T
- 3. Inference and Explicit Type Arguments
- 4. Several Type Parameters
- 5. Naming Type Parameters
- 6. A Generic Stack<E>
- 7. A Result<T> Holder
- 8. Generic Functions
- 9. A Generic swap
- 10. Generic Methods Inside a Class
- 11. When Inference Guesses Too Wide
- 12. Generic Typedefs Revisited
- 13. A Real-World Example
- Code Examples
- Common Beginner Mistakes
- Best Practices
- Mini Challenge
- Practice Questions
- Practice Answers
- Quick Revision
- Key Takeaways
- What's Next?
Lecture 73 — Generic Classes & Functions
| Category | 12 — Generics & Advanced Types |
| Lecture | 073 of 140 |
| Estimated video length | 12 min |
| Target | Dart 3.12.2 |
| Level | Intermediate |
So far,
List<T>andMap<K, V>came ready-made from Dart. Now you will write your own generic classes and functions: oneStack<T>, onefirstOrDefault<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
Objectanddynamiclose 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>andList<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:
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 aResult<T> - Write generic functions such as
firstOrDefault<T>andswap<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:
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());
}90
ALIInside 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:
class Box<T> {
T value;
Box(this.value);
}
void main() {
var marks = Box<int>(85);
marks.value = 90;
// marks.value = 'ninety'; // ✗
print(marks.value);
}90Uncommenting the last assignment gives:
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:
class Box<T> {
T value;
Box(this.value);
}
void main() {
var price = Box(1500);
var city = Box('Lahore');
print(price.runtimeType);
print(city.runtimeType);
}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:
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);
}(Sara, 92)
60.5student 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:
| Letter | Meaning | Example |
|---|---|---|
T | any Type | Box<T> |
E | an Element of a collection | List<E>, Stack<E> |
K, V | a map's Key and Value | Map<K, V> |
R | a Return or result type | R Function(T) |
A, B | several unrelated types | Pair<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:
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());
}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:
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}');
}
}Marks: 85
Error: "abc" is not a numberInside 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:
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'));
}80
nobodyIn 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:
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);
}[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:
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);
}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:
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);
}Ali
3Uncommenting name.length gives:
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:
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}');
}86.0
Sara: 92NamedMarks is just another name for Pair<String, int>, exactly like the aliases from
Lecture 071.
13. A Real-World Example
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}');
}Undo: Bag
Pay: 200The 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>
class Box<T> {
T value;
Box(this.value);
}
void main() {
var age = Box(20);
var city = Box('Karachi');
print('${age.value} ${city.value}');
}20 KarachiExample 2 — Explicit Type Argument
class Box<T> {
T value;
Box(this.value);
}
void main() {
var price = Box<double>(99);
print(price.value);
}99.0How it works
Box<double>fixesTasdoublebefore the argument is read.99is then treated as adouble;Box(99)would have made aBox<int>.
Example 3 — A Pair<A, B>
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}');
}Zayan present: trueExample 4 — A Generic Function
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));
}Sara
-1Example 5 — swap on Integers
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);
}[90, 40, 65]Example 6 — A Generic Method
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);
}50 Pen
Pair<int, String>How it works
swapped()returnsPair<B, A>— the same two type parameters in reverse order.Pair<String, int>becomesPair<int, String>.
Example 7 — Student Marks Stack
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');
}Total: 240Example 8 — Shopping Prices with firstOrDefault
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)}');
}First item: 1500
First item: 0Example 9 — Bank Withdrawal with Result<T>
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);
}700.0
Insufficient fundsHow it works
- The return type
Result<double>decidesTfor both constructors. - A successful result has a
valueand noerror; a failed one the opposite, so??picks whichever is set.
Example 10 — Temperature Conversion with map<R>
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}');
}77.0 pleasantCommon Beginner Mistakes
1. Using a type parameter that was never declared
// E pick(List<E> items) => items[0]; // ✗ missing <E>
E pick<E>(List<E> items) => items[0];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
class Box<T> {
T value;
Box(this.value);
// static T? last; // ✗
}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
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);
}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
void show<T>(T value) {
// print(value.length); // ✗
}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
T firstOrDefault<T>(List<T> items, T fallback) =>
items.isEmpty ? fallback : items[0];
void main() {
// print(firstOrDefault<int>(['a'], 0)); // ✗
}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,Efor elements,K/Vfor keys and values,Rfor 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:
Ali: 2
Sara: 1Try it yourself before opening the solution.
Show solution
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')}');
}Ali: 2
Sara: 1How it works
<T, int>{}creates a map whose keys have the counter's type.countOfreturns0for a value not seen yet, soaddcan always add1.
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
- Write
class Box<T>with one fieldvalue. Create a box holding'Sara'and print its value in upper case. - Write
class Pair<A, B>. Create a pair of'Bag'and1500and printBag costs 1500. - What does
print(Box(3.5).runtimeType);print? Write the program to check.
Application
- 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']. - Add an
E? peek()method toStack<E>that returns the top item without removing it. Push5and9, then printpeek()twice. - Use
swapfrom section 9 to turn['Zayan', 'Ali']into['Ali', 'Zayan']. - Write
Result<int> safeDivide(int a, int b)that fails with'Cannot divide by zero'whenbis0and otherwise returnsa ~/ b. Print the results for10, 2and10, 0.
Challenge
- Write a generic function
List<R> convertAll<T, R>(List<T> items, R Function(T) f)without usingmap. Convert[25.0, 30.0]from Celsius to Fahrenheit. - Write
typedef Score = Pair<String, int>;and a functionScore best(List<Score> scores)that returns the highest. Print it for Ali80, Sara92, Zayan67. firstOrDefault(['Ali'], 0)compiles. Explain in one sentence what typeTgets, and rewrite the call so Dart rejects the0.
Practice Answers
Question 1 — Solution
class Box<T> {
T value;
Box(this.value);
}
void main() {
var name = Box('Sara');
print(name.value.toUpperCase());
}SARAQuestion 2 — Solution
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}');
}Bag costs 1500Question 3 — Solution
class Box<T> {
T value;
Box(this.value);
}
void main() {
print(Box(3.5).runtimeType);
}Box<double>Question 4 — Solution
T middle<T>(List<T> items) => items[items.length ~/ 2];
void main() {
print(middle([10, 20, 30]));
print(middle(['Ali', 'Sara', 'Zayan']));
}20
SaraQuestion 5 — Solution
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());
}9
9Question 6 — Solution
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);
}[Ali, Zayan]Question 7 — Solution
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);
}
}5
Cannot divide by zeroQuestion 8 — Solution
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));
}[77.0, 86.0]Question 9 — Solution
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}');
}Sara: 92Question 10 — Solution
T becomes Object, the closest type shared by String and int. Writing the type
argument makes the mistake an error:
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'));
}AliThe commented-out call gives
Error: The argument type 'int' can't be assigned to the parameter type 'String'.
Quick Revision
- Where do you write the type parameter of a generic class? Of a generic function?
- How many type parameters can a class have?
- What does each letter usually mean:
T,E,K,V,R? - Why does
pop()inStack<E>returnE?? - What does
Stack()with no type argument become? - How is
map<R>inBox<T>different from the class'sT? - Why can't a static field use
T? - Why can't you call
.lengthon a value of typeT?
Key Takeaways
class Box<T>declares a type parameter; everyTinside is replaced when you writeBox<int>orBox<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,Rby convention. - Dart infers type arguments from arguments and context; empty objects fall back to
dynamic, and disagreeing arguments toObject. - 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.