On this page22 sections
- Prerequisites
- Connection With Previous Lectures
- Learning Objectives
- 1. What Is a Subtype?
- 2. Your Own Classes Form Subtypes
- 3. The Type Ladder
- 4. Generics Follow Their Type Argument
- 5. Where Covariance Bites
- 6. Why Dart Allows It — and How to Stay Safe
- 7. Function Return Types Are Covariant Too
- 8. Parameter Types Flip Direction
- 9. The covariant Keyword
- 10. 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 75 — Variance & Type Relationships
| Category | 12 — Generics & Advanced Types |
| Lecture | 075 of 140 |
| Estimated video length | 10 min |
| Target | Dart 3.12.2 |
| Level | Intermediate |
A
Dogfits where anAnimalis expected. But does aList<Dog>fit where aList<Animal>is expected? Does a function about dogs fit where a function about animals is expected? Variance is the set of rules that answers these questions.
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, 013, 035, 059, 065 and 072–074:
Object,dynamic, and nullable types such asString?- function types such as
void Function(int) - subclasses with
extends, and parent-typed variables holding child objects - generic types such as
List<T>, and bounds such asT extends num
Connection With Previous Lectures
In Lecture 065, a function that accepted an Animal happily took a Dog. In Lecture 074,
T extends num let int and double in. Both rely on one idea: some types fit inside
others. But we never asked what happens when the type sits inside angle brackets. Try it:
class Animal {}
class Dog extends Animal {}
void countAnimals(List<Animal> animals) => print(animals.length);
void main() {
List<Dog> dogs = [Dog(), Dog()];
countAnimals(dogs); // allowed?
}2It works. This lecture explains why, where this rule bites, and how function types follow a surprising rule of their own.
Learning Objectives
By the end of this lecture you will be able to:
- Explain what "A is a subtype of B" means
- Place
Object?,dynamic,Object,NullandNeveron Dart's type ladder - Explain why
List<Dog>is a subtype ofList<Animal>(covariance) - Predict the runtime
TypeErrorwhen aCatis added through aList<Animal>view - Keep lists safe with copies and read-only
Iterableparameters - Explain why a function's return type may be narrower
- Explain why a function's parameter type may only be wider
- Recognize the
covariantkeyword and the risk it brings
1. What Is a Subtype?
Type A is a subtype of type B when every A value is also a B value. Then an A
can go anywhere a B is expected — but not the other way round.
Analogy — every dog is an animal, but not every animal is a dog. A vet who treats animals can treat your dog; a dog groomer cannot be handed any animal.
int is a subtype of num:
void main() {
int count = 5;
num amount = count; // ✓ every int is a num
print(amount);
}5Going the other way fails, even though amount holds an int right now:
void main() {
num amount = 5;
// int count = amount; // ✗
print(amount);
}Error: A value of type 'num' can't be assigned to a variable of type 'int'.
int count = amount;
^2. Your Own Classes Form Subtypes
Every extends creates a subtype. We will use this small family through the lecture:
class Animal {
final String name;
Animal(this.name);
}
class Dog extends Animal {
Dog(super.name);
}
class Cat extends Animal {
Cat(super.name);
}
void main() {
Animal pet = Dog('Rex'); // ✓ Dog is an Animal
print(pet is Dog);
print(pet is Cat);
}true
falseDog and Cat are subtypes of Animal, but not of each other.
3. The Type Ladder
Picture all types on a ladder. Each rung is a subtype of the rungs above it:
| Rung | Type | What it holds |
|---|---|---|
| Top | Object? and dynamic | Any value, including null |
Object | Any value except null | |
num, String, Animal, … | Their own values | |
int, Dog, … | Their own values | |
Null | Only null (it sits under every ? type) | |
| Bottom | Never | No value at all |
void main() {
Object? anything = null; // ✓ top of the ladder
anything = 42;
Object something = 'Sara';
// Object nothing = null; // ✗
print('$anything $something');
}42 SaraError: A value of type 'Null' can't be assigned to a variable of type 'Object'.
Object nothing = null;
^dynamic sits at the top too, but with checking switched off (Lecture 011). Never is a
subtype of every type because it has no values; you will see it as the return type of a
function that always throws an exception (Lecture 085 — throw & Custom Exceptions).
4. Generics Follow Their Type Argument
In Dart, if Dog is a subtype of Animal, then List<Dog> is a subtype of List<Animal>.
This is called covariance — "varying together": the generic type moves in the same
direction as its type argument.
class Animal {
final String name;
Animal(this.name);
}
class Dog extends Animal {
Dog(super.name);
}
void printNames(List<Animal> animals) {
for (var a in animals) {
print(a.name);
}
}
void main() {
List<Dog> dogs = [Dog('Rex'), Dog('Buddy')];
printNames(dogs); // ✓ List<Dog> is a List<Animal>
}Rex
BuddyReading is perfectly safe: every item in a List<Dog> really is an Animal. The same rule
holds for Set, Map, and your own generic classes from Lecture 073.
5. Where Covariance Bites
Now add through the List<Animal> view. The compiler sees List<Animal>, and a Cat is
an Animal, so it compiles:
class Animal {
final String name;
Animal(this.name);
}
class Dog extends Animal {
Dog(super.name);
}
class Cat extends Animal {
Cat(super.name);
}
void main() {
List<Dog> dogs = [Dog('Rex')];
List<Animal> animals = dogs;
print(animals.runtimeType);
animals.add(Cat('Tom'));
print('never printed');
}List<Dog>
Unhandled exception:
type 'Cat' is not a subtype of type 'Dog' of 'value'animals and dogs are one list object, and it remembers it was created as a
List<Dog>. When add runs, the list checks the new item and throws a TypeError, so a
cat never sneaks into the dog list.
Dart also prints a few lines showing where the crash happened. We will read them in Lecture 083 — Errors vs Exceptions.
6. Why Dart Allows It — and How to Stay Safe
Some languages forbid List<Dog> → List<Animal> to rule out this crash. Dart chose
simplicity: most code only reads lists, so passing a List<Dog> to a function that
prints animals should just work. The price is a runtime check on writes.
Two habits keep you safe. Take an Iterable<Animal> when a function only reads — it has no
add at all. And when you need to add, make a new list of the wider type:
class Animal {
final String name;
Animal(this.name);
}
class Dog extends Animal {
Dog(super.name);
}
class Cat extends Animal {
Cat(super.name);
}
void printNames(Iterable<Animal> animals) {
print(animals.map((a) => a.name).join(', '));
}
void main() {
List<Dog> dogs = [Dog('Rex')];
List<Animal> shelter = List<Animal>.of(dogs); // a new List<Animal>
shelter.add(Cat('Tom')); // ✓
printNames(shelter);
printNames(dogs);
}Rex, Tom
RexList<Animal>.of(dogs) copies the items into a new list created for animals, so any
animal may join it.
7. Function Return Types Are Covariant Too
A function type has a return type and parameter types (Lecture 035). The return type follows
the same rule as lists: a function that returns a Dog can be used where a function that
returns an Animal is expected.
class Animal {
final String name;
Animal(this.name);
}
class Dog extends Animal {
Dog(super.name);
}
Dog adoptDog() => Dog('Rex');
void main() {
Animal Function() adopt = adoptDog; // ✓
print(adopt().name);
}RexWhoever calls adopt() expects an Animal and gets a Dog, which is an animal.
8. Parameter Types Flip Direction
Parameters work the opposite way. Ali needs a void Function(Dog) — something that
handles dogs. A function that handles any animal can do that job:
class Animal {
final String name;
Animal(this.name);
}
class Dog extends Animal {
Dog(super.name);
}
void feedAnimal(Animal a) => print('Feeding ${a.name}');
void main() {
void Function(Dog) feedDog = feedAnimal; // ✓
feedDog(Dog('Rex'));
}Feeding RexThe reverse is rejected at compile time. A function that only handles dogs cannot promise to handle every animal — someone could pass it a cat:
class Animal {}
class Dog extends Animal {}
void washDog(Dog d) => print('Washing a dog');
void main() {
// void Function(Animal) wash = washDog; // ✗
}Error: A value of type 'void Function(Dog)' can't be assigned to a variable of type 'void Function(Animal)'.This flip is called contravariance. A short rule to remember: a function may ask for less (wider parameter) and give more (narrower return).
9. The covariant Keyword
Lecture 060 showed that an override must keep the parent's parameter type — and the error
message mentioned a covariant keyword. Writing covariant lets an override narrow a
parameter on purpose, and moves the check to runtime:
class Animal {
void playWith(Animal other) => print('Animals play');
}
class Dog extends Animal {
@override
void playWith(covariant Dog other) => print('Dogs play fetch');
}
class Cat extends Animal {}
void main() {
Animal pet = Dog();
pet.playWith(Dog());
pet.playWith(Cat());
}Dogs play fetch
Unhandled exception:
type 'Cat' is not a subtype of type 'Dog' of 'other'It is the same hole as section 5, opened deliberately. You will rarely need it; taking
the wider type and checking with is is clearer.
10. A Real-World Example
Sara's school stores students in a List<Student>. One "by name" comparison written for
any Person sorts students too, because parameters may be wider:
class Person {
final String name;
Person(this.name);
}
class Student extends Person {
final int marks;
Student(super.name, this.marks);
}
int byName(Person a, Person b) => a.name.compareTo(b.name);
void printPeople(Iterable<Person> people) {
for (var p in people) {
print(p.name);
}
}
void main() {
List<Student> students = [
Student('Zayan', 65),
Student('Ali', 80),
Student('Sara', 90),
];
students.sort(byName); // expects int Function(Student, Student)
printPeople(students); // List<Student> is an Iterable<Person>
List<Person> assembly = List<Person>.of(students)..add(Person('Mr. Khan'));
print('Assembly: ${assembly.length} people');
}Ali
Sara
Zayan
Assembly: 4 peoplesort wants an int Function(Student, Student); byName asks for less (any Person), so it
fits. printPeople only reads, so covariance is safe. To add a teacher, we copy into a new
List<Person>.
Code Examples
Example 1 — int Is a num
void main() {
List<num> values = [];
int whole = 7;
double half = 0.5;
values.add(whole);
values.add(half);
print(values);
}[7, 0.5]Example 2 — Checking Subtypes with is
class Animal {}
class Dog extends Animal {}
void main() {
Object pet = Dog();
print(pet is Animal);
print(pet is Dog);
print(pet is String);
}true
true
falseExample 3 — A List of Subtypes Fits a List of the Parent
void printTotal(List<num> values) {
num total = 0;
for (var v in values) {
total += v;
}
print(total);
}
void main() {
List<int> marks = [80, 75, 85];
printTotal(marks);
}240Example 4 — The List Remembers Its Type
void main() {
List<int> marks = [80, 75];
List<num> view = marks;
print(view.runtimeType);
print(view is List<int>);
}List<int>
trueExample 5 — The Runtime TypeError
void main() {
List<int> marks = [80, 75];
List<num> view = marks;
view.add(92.5);
print(marks);
}Unhandled exception:
type 'double' is not a subtype of type 'int' of 'value'Example 6 — Copying to Widen Safely
void main() {
List<int> marks = [80, 75];
List<num> scores = List<num>.of(marks);
scores.add(92.5);
print(scores);
print(marks);
}[80, 75, 92.5]
[80, 75]Example 7 — Student Marks Report
void report(Iterable<num> marks) {
num total = 0;
for (var m in marks) {
total += m;
}
print('Average: ${total / marks.length}');
}
void main() {
List<int> ali = [80, 75, 85];
List<double> sara = [90.5, 88.0];
report(ali);
report(sara);
}Average: 80.0
Average: 89.25How it works
List<int>andList<double>are both subtypes ofIterable<num>.reportonly reads with a loop, so no runtime check can fail.
Example 8 — Shopping Total with a Wide Price Rule
double addTax(num price) => price * 1.1;
void main() {
List<int> prices = [100, 200];
double Function(int) rule = addTax; // ✓ asks for less
for (var p in prices) {
print(rule(p).toStringAsFixed(2));
}
}110.00
220.00Example 9 — Bank Accounts
class Account {
double balance;
Account(this.balance);
}
class SavingsAccount extends Account {
SavingsAccount(super.balance);
}
SavingsAccount openSavings() => SavingsAccount(500);
void main() {
Account Function() open = openSavings; // ✓ gives more
var account = open();
account.balance += 250;
print('Balance: ${account.balance}');
}Balance: 750.0Example 10 — Temperature Alerts
void alert(num reading) => print('Reading: $reading');
void checkAll(List<double> temps, void Function(double) onHot) {
for (var t in temps) {
if (t > 35) onHot(t);
}
}
void main() {
checkAll([28.5, 37.0, 41.2], alert);
}Reading: 37.0
Reading: 41.2How it works
checkAllexpects avoid Function(double).alertaccepts anynum, which includes everydouble, so it fits.
Common Beginner Mistakes
1. Assuming a parent-typed value is the child type
void main() {
num price = 250;
// int rounded = price; // ✗
print(price);
}Error: A value of type 'num' can't be assigned to a variable of type 'int'.The variable's type is num, whatever it holds now. Use is to check first (Lecture 010).
2. Adding through a wider view
Writing a Cat into a List<Animal> that is really a List<Dog> compiles, then crashes
with type 'Cat' is not a subtype of type 'Dog' of 'value' (section 5). Copy into a new
list instead.
3. Passing a narrow function where a wide one is expected
void printInt(int n) => print(n);
void main() {
// void Function(num) show = printInt; // ✗
}Error: A value of type 'void Function(int)' can't be assigned to a variable of type 'void Function(num)'.A void Function(num) may be called with 2.5, and printInt cannot take that.
4. Passing a callback through a wider view
void printTotal(List<num> values) {
// print(values.reduce((a, b) => a + b)); // ✗ if values is really a List<int>
}
void main() {
printTotal(<int>[80, 75]);
}Unhandled exception:
type '(num, num) => num' is not a subtype of type '(int, int) => int' of 'combine'The list is really a List<int>, so its reduce needs a function returning int. Our
function returns num — a wider return type (section 7). Sum with a loop instead
(Example 3).
Best Practices
- Accept the widest type a function really needs:
Iterable<Animal>overList<Dog>. - Use
Iterable<T>for parameters that only read; it has noadd. - Don't add items through a list you received as a wider type; build a new list with
List<Wider>.of(...). - Treat
covariantas a last resort.
Mini Challenge
Write void greetAll(Iterable<Person> people) that prints Hello, <name> for each person.
Call it with a List<Student> holding Ali and Sara. Then build a List<Person> from the
students, add Person('Zayan'), and greet that list too.
Your program should print exactly:
Hello, Ali
Hello, Sara
---
Hello, Ali
Hello, Sara
Hello, ZayanTry it yourself before opening the solution.
Show solution
class Person {
final String name;
Person(this.name);
}
class Student extends Person {
Student(super.name);
}
void greetAll(Iterable<Person> people) {
for (var p in people) {
print('Hello, ${p.name}');
}
}
void main() {
List<Student> students = [Student('Ali'), Student('Sara')];
greetAll(students);
print('---');
List<Person> everyone = List<Person>.of(students);
everyone.add(Person('Zayan'));
greetAll(everyone);
}Hello, Ali
Hello, Sara
---
Hello, Ali
Hello, Sara
Hello, ZayanHow it works
- A
List<Student>is anIterable<Person>by covariance, andgreetAllonly reads. everyoneis a newList<Person>, so adding a plainPersonis safe.
Practice Questions
Work through these in order — they run from beginner to application to challenge.
Beginner
- Assign the
int42to anObjectvariable, then printvalue is num. - Can
nullbe stored in a variable of typeObject? InObject?? Answer, then prove the second with code. - Create
List<String> names = ['Ali', 'Sara'];, assign it to aList<Object>variable, and print that variable'sruntimeType.
Application
- Write
void printAll(List<Object> items)and call it with[1, 2, 3]typed asList<int>. - Predict the outcome: a
List<String>viewed asList<Object>, then.add(5). Run it. - Fix question 5 so the
5can be added, without changing the original list. - Store
int square(num n) => (n * n).toInt();in a variable of typeint Function(int)and print the result for6.
Challenge
- Which assignment compiles:
num Function() a = getInt;orint Function() b = getNum;(withint getInt() => 1;andnum getNum() => 1;)? Prove it with the valid one. - Sort
[Student('Sara'), Student('Ali'), Student('Zayan')]with a comparison written forPerson, and print the names. - Explain in one sentence why
List<Dog>→List<Animal>is allowed butvoid Function(Dog)→void Function(Animal)is not.
Practice Answers
Question 1 — Solution
void main() {
Object value = 42;
print(value is num);
}trueQuestion 2 — Solution
Not in Object (a compile-time error); yes in Object?.
void main() {
Object? maybe = null;
print(maybe);
}nullQuestion 3 — Solution
void main() {
List<String> names = ['Ali', 'Sara'];
List<Object> items = names;
print(items.runtimeType);
}List<String>Question 4 — Solution
void printAll(List<Object> items) {
for (var item in items) {
print(item);
}
}
void main() {
List<int> numbers = [1, 2, 3];
printAll(numbers);
}1
2
3Question 5 — Solution
It compiles, then throws a TypeError at runtime:
void main() {
List<String> names = ['Ali'];
List<Object> items = names;
items.add(5);
}Unhandled exception:
type 'int' is not a subtype of type 'String' of 'value'Question 6 — Solution
void main() {
List<String> names = ['Ali'];
List<Object> items = List<Object>.of(names);
items.add(5);
print(items);
print(names);
}[Ali, 5]
[Ali]Question 7 — Solution
int square(num n) => (n * n).toInt();
void main() {
int Function(int) f = square;
print(f(6));
}36Question 8 — Solution
num Function() a = getInt; compiles: a function may return something narrower. The other
fails, because getNum might return a double.
int getInt() => 1;
void main() {
num Function() a = getInt;
print(a());
}1Question 9 — Solution
class Person {
final String name;
Person(this.name);
}
class Student extends Person {
Student(super.name);
}
int byName(Person a, Person b) => a.name.compareTo(b.name);
void main() {
var students = [Student('Sara'), Student('Ali'), Student('Zayan')];
students.sort(byName);
print(students.map((s) => s.name).toList());
}[Ali, Sara, Zayan]Question 10 — Solution
Reading dogs as animals is always safe (and Dart checks writes at runtime), but a function that only handles dogs could be called with a cat, so Dart rejects it at compile time.
Quick Revision
- What does "
intis a subtype ofnum" mean? - Which types sit at the top and bottom of Dart's type ladder?
- Is
List<int>a subtype ofList<num>? What is this rule called? - What happens when you add a
doubleto aList<int>through aList<num>view? - How do you widen a list safely?
- Can a
Dog Function()be used as anAnimal Function()? - Can a
void Function(Dog)be used as avoid Function(Animal)? - What does
covariantdo to an overriding parameter?
Key Takeaways
Ais a subtype ofBwhen everyAis aB; subtypes fit where the parent is expected.- The ladder runs from
Object?/dynamicat the top, throughObject, down toNullandNever. - Dart generics are covariant:
List<Dog>is aList<Animal>. - Writing through a wider view compiles but throws a runtime TypeError; the list remembers its real type.
- Dart chose this for simplicity; stay safe with
Iterableparameters andList<T>.ofcopies. - Function return types are covariant; parameter types are contravariant — ask for less, give more.
covariantnarrows an override's parameter and moves the check to runtime.
What's Next?
Lecture 76 — Records. This closes the generics lectures. So far, returning two values — a name and a mark — meant building a class or squeezing them into a list. Next we will meet records, Dart's lightweight way to group a few values together with full type safety.