Watch the video (10 min)
On this page22 sections
  1. Prerequisites
  2. Connection With Previous Lectures
  3. Learning Objectives
  4. 1. What Is a Subtype?
  5. 2. Your Own Classes Form Subtypes
  6. 3. The Type Ladder
  7. 4. Generics Follow Their Type Argument
  8. 5. Where Covariance Bites
  9. 6. Why Dart Allows It — and How to Stay Safe
  10. 7. Function Return Types Are Covariant Too
  11. 8. Parameter Types Flip Direction
  12. 9. The covariant Keyword
  13. 10. A Real-World Example
  14. Code Examples
  15. Common Beginner Mistakes
  16. Best Practices
  17. Mini Challenge
  18. Practice Questions
  19. Practice Answers
  20. Quick Revision
  21. Key Takeaways
  22. What's Next?

Lecture 75 — Variance & Type Relationships

Category12 — Generics & Advanced Types
Lecture075 of 140
Estimated video length10 min
TargetDart 3.12.2
LevelIntermediate

A Dog fits where an Animal is expected. But does a List<Dog> fit where a List<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 as String?
  • 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 as T 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:

dart
class Animal {}

class Dog extends Animal {}

void countAnimals(List<Animal> animals) => print(animals.length);

void main() {
  List<Dog> dogs = [Dog(), Dog()];
  countAnimals(dogs); // allowed?
}
output
2

It 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, Null and Never on Dart's type ladder
  • Explain why List<Dog> is a subtype of List<Animal> (covariance)
  • Predict the runtime TypeError when a Cat is added through a List<Animal> view
  • Keep lists safe with copies and read-only Iterable parameters
  • Explain why a function's return type may be narrower
  • Explain why a function's parameter type may only be wider
  • Recognize the covariant keyword 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:

dart
void main() {
  int count = 5;
  num amount = count; // ✓ every int is a num
  print(amount);
}
output
5

Going the other way fails, even though amount holds an int right now:

dart
void main() {
  num amount = 5;
  // int count = amount; // ✗
  print(amount);
}
output
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:

dart
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);
}
output
true
false

Dog 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:

RungTypeWhat it holds
TopObject? and dynamicAny value, including null
ObjectAny value except null
num, String, Animal, …Their own values
int, Dog, …Their own values
NullOnly null (it sits under every ? type)
BottomNeverNo value at all
dart
void main() {
  Object? anything = null; // ✓ top of the ladder
  anything = 42;
  Object something = 'Sara';
  // Object nothing = null; // ✗
  print('$anything $something');
}
output
42 Sara
output
Error: 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.

dart
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>
}
output
Rex
Buddy

Reading 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:

dart
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');
}
output
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:

dart
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);
}
output
Rex, Tom
Rex

List<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.

dart
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);
}
output
Rex

Whoever 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:

dart
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'));
}
output
Feeding Rex

The reverse is rejected at compile time. A function that only handles dogs cannot promise to handle every animal — someone could pass it a cat:

dart
class Animal {}

class Dog extends Animal {}

void washDog(Dog d) => print('Washing a dog');

void main() {
  // void Function(Animal) wash = washDog; // ✗
}
output
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:

dart
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());
}
output
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:

dart
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');
}
output
Ali
Sara
Zayan
Assembly: 4 people

sort 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

dart
void main() {
  List<num> values = [];
  int whole = 7;
  double half = 0.5;
  values.add(whole);
  values.add(half);
  print(values);
}
output
[7, 0.5]

Example 2 — Checking Subtypes with is

dart
class Animal {}

class Dog extends Animal {}

void main() {
  Object pet = Dog();
  print(pet is Animal);
  print(pet is Dog);
  print(pet is String);
}
output
true
true
false

Example 3 — A List of Subtypes Fits a List of the Parent

dart
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);
}
output
240

Example 4 — The List Remembers Its Type

dart
void main() {
  List<int> marks = [80, 75];
  List<num> view = marks;
  print(view.runtimeType);
  print(view is List<int>);
}
output
List<int>
true

Example 5 — The Runtime TypeError

dart
void main() {
  List<int> marks = [80, 75];
  List<num> view = marks;
  view.add(92.5);
  print(marks);
}
output
Unhandled exception:
type 'double' is not a subtype of type 'int' of 'value'

Example 6 — Copying to Widen Safely

dart
void main() {
  List<int> marks = [80, 75];
  List<num> scores = List<num>.of(marks);
  scores.add(92.5);
  print(scores);
  print(marks);
}
output
[80, 75, 92.5]
[80, 75]

Example 7 — Student Marks Report

dart
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);
}
output
Average: 80.0
Average: 89.25

How it works

  1. List<int> and List<double> are both subtypes of Iterable<num>.
  2. report only reads with a loop, so no runtime check can fail.

Example 8 — Shopping Total with a Wide Price Rule

dart
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));
  }
}
output
110.00
220.00

Example 9 — Bank Accounts

dart
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}');
}
output
Balance: 750.0

Example 10 — Temperature Alerts

dart
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);
}
output
Reading: 37.0
Reading: 41.2

How it works

  1. checkAll expects a void Function(double).
  2. alert accepts any num, which includes every double, so it fits.

Common Beginner Mistakes

1. Assuming a parent-typed value is the child type

dart
void main() {
  num price = 250;
  // int rounded = price; // ✗
  print(price);
}
output
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

dart
void printInt(int n) => print(n);

void main() {
  // void Function(num) show = printInt; // ✗
}
output
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

dart
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]);
}
output
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> over List<Dog>.
  • Use Iterable<T> for parameters that only read; it has no add.
  • Don't add items through a list you received as a wider type; build a new list with List<Wider>.of(...).
  • Treat covariant as 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:

output
Hello, Ali
Hello, Sara
---
Hello, Ali
Hello, Sara
Hello, Zayan

Try it yourself before opening the solution.

Show solution
dart
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);
}
output
Hello, Ali
Hello, Sara
---
Hello, Ali
Hello, Sara
Hello, Zayan

How it works

  1. A List<Student> is an Iterable<Person> by covariance, and greetAll only reads.
  2. everyone is a new List<Person>, so adding a plain Person is safe.

Practice Questions

Work through these in order — they run from beginner to application to challenge.

Beginner

  1. Assign the int 42 to an Object variable, then print value is num.
  2. Can null be stored in a variable of type Object? In Object?? Answer, then prove the second with code.
  3. Create List<String> names = ['Ali', 'Sara'];, assign it to a List<Object> variable, and print that variable's runtimeType.

Application

  1. Write void printAll(List<Object> items) and call it with [1, 2, 3] typed as List<int>.
  2. Predict the outcome: a List<String> viewed as List<Object>, then .add(5). Run it.
  3. Fix question 5 so the 5 can be added, without changing the original list.
  4. Store int square(num n) => (n * n).toInt(); in a variable of type int Function(int) and print the result for 6.

Challenge

  1. Which assignment compiles: num Function() a = getInt; or int Function() b = getNum; (with int getInt() => 1; and num getNum() => 1;)? Prove it with the valid one.
  2. Sort [Student('Sara'), Student('Ali'), Student('Zayan')] with a comparison written for Person, and print the names.
  3. Explain in one sentence why List<Dog> → List<Animal> is allowed but void Function(Dog) → void Function(Animal) is not.

Practice Answers

Question 1 — Solution

dart
void main() {
  Object value = 42;
  print(value is num);
}
output
true

Question 2 — Solution

Not in Object (a compile-time error); yes in Object?.

dart
void main() {
  Object? maybe = null;
  print(maybe);
}
output
null

Question 3 — Solution

dart
void main() {
  List<String> names = ['Ali', 'Sara'];
  List<Object> items = names;
  print(items.runtimeType);
}
output
List<String>

Question 4 — Solution

dart
void printAll(List<Object> items) {
  for (var item in items) {
    print(item);
  }
}

void main() {
  List<int> numbers = [1, 2, 3];
  printAll(numbers);
}
output
1
2
3

Question 5 — Solution

It compiles, then throws a TypeError at runtime:

dart
void main() {
  List<String> names = ['Ali'];
  List<Object> items = names;
  items.add(5);
}
output
Unhandled exception:
type 'int' is not a subtype of type 'String' of 'value'

Question 6 — Solution

dart
void main() {
  List<String> names = ['Ali'];
  List<Object> items = List<Object>.of(names);
  items.add(5);
  print(items);
  print(names);
}
output
[Ali, 5]
[Ali]

Question 7 — Solution

dart
int square(num n) => (n * n).toInt();

void main() {
  int Function(int) f = square;
  print(f(6));
}
output
36

Question 8 — Solution

num Function() a = getInt; compiles: a function may return something narrower. The other fails, because getNum might return a double.

dart
int getInt() => 1;

void main() {
  num Function() a = getInt;
  print(a());
}
output
1

Question 9 — Solution

dart
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());
}
output
[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

  1. What does "int is a subtype of num" mean?
  2. Which types sit at the top and bottom of Dart's type ladder?
  3. Is List<int> a subtype of List<num>? What is this rule called?
  4. What happens when you add a double to a List<int> through a List<num> view?
  5. How do you widen a list safely?
  6. Can a Dog Function() be used as an Animal Function()?
  7. Can a void Function(Dog) be used as a void Function(Animal)?
  8. What does covariant do to an overriding parameter?

Key Takeaways

  • A is a subtype of B when every A is a B; subtypes fit where the parent is expected.
  • The ladder runs from Object?/dynamic at the top, through Object, down to Null and Never.
  • Dart generics are covariant: List<Dog> is a List<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 Iterable parameters and List<T>.of copies.
  • Function return types are covariant; parameter types are contravariant — ask for less, give more.
  • covariant narrows 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.


Video lecture

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