Watch the video (8 min)
On this page21 sections
  1. Prerequisites
  2. Connection With Previous Lectures
  3. Learning Objectives
  4. 1. What Immutability Means
  5. 2. Two Names, One Object
  6. 3. An Immutable Class
  7. 4. "Changing" an Immutable Object: copyWith
  8. 5. final Is Not Deeply Immutable
  9. 6. The Fix: A Defensive Copy
  10. 7. Records Are Shallow Too
  11. 8. @immutable from package:meta
  12. 9. A Real-World Example
  13. Code Examples
  14. Common Beginner Mistakes
  15. Best Practices
  16. Mini Challenge
  17. Practice Questions
  18. Practice Answers
  19. Quick Revision
  20. Key Takeaways
  21. What's Next?

Lecture 124 — Immutability

Category21 — Professional Dart Development
Lecture124 of 140
Estimated video length8 min
TargetDart 3.12.2
LevelAdvanced

When two parts of a program share one object, a change made by one surprises the other. An immutable object cannot change at all, so it can be shared safely.

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 006–123:

  • final and const (Lecture 006)
  • spread, List.unmodifiable, and const lists (Lecture 046)
  • final fields and const constructors (Lectures 053–055)
  • records and why they are immutable (Lecture 076)
  • adding a package such as meta to pubspec.yaml (Lectures 103–105)
  • dart analyze and its warnings (Lectures 114 and 117)

Connection With Previous Lectures

Ali's marks are stored in a list, and a second variable is created for a report:

dart
void main() {
  final aliMarks = [80, 75, 85];
  final report = aliMarks; // a second name, not a copy

  report.add(0); // someone "just testing" the report

  print(aliMarks);
}
output
[80, 75, 85, 0]

Nobody touched aliMarks, yet it changed. Both variables are final, and still nothing stopped it. In a large program this kind of change is very hard to track down. This lecture shows how to build values that cannot change, and how to "change" them anyway.


Learning Objectives

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

  • Explain what immutability is and why it prevents surprise changes
  • Recognize when two variables share one object
  • Build immutable classes with final fields and a const constructor
  • Use the copyWith pattern to get a changed copy of an object
  • Explain the difference between final and deeply immutable
  • Protect list fields with List.unmodifiable and defensive copies
  • Use @immutable from package:meta to let the analyzer check a class

1. What Immutability Means

An object is immutable when nothing about it can change after it is created. You already know two: strings and records.

Analogy — a mutable object is a whiteboard: anyone holding the marker can rewrite it. An immutable object is a printed receipt: to "change" it, you print a new one, and the old one stays exactly as it was.

dart
void main() {
  const name = 'Ali';
  final upper = name.toUpperCase();
  print(name);
  print(upper);
}
output
Ali
ALI

toUpperCase() did not change name. It returned a new string.


2. Two Names, One Object

The bug in the Connection happened because final report = aliMarks; copies the reference, not the list. identical (Lecture 055) proves it:

dart
void main() {
  final aliMarks = [80, 75, 85];
  final report = aliMarks;
  print(identical(aliMarks, report));
}
output
true

There is one list with two names. Any change made through either name is seen through both. With an immutable object, sharing is harmless, because no one can change it.


3. An Immutable Class

To make a class immutable, make every field final and add a const constructor:

dart
class Account {
  final String owner;
  final int balance;

  const Account(this.owner, this.balance);
}

void main() {
  const account = Account('Sara', 1000);
  print('${account.owner}: ${account.balance}');
}
output
Sara: 1000

A final field has no setter, so trying to assign to it is a compile error:

dart
void main() {
  const account = Account('Sara', 1000);
  account.balance = 500; // ✗
}
output
Error: The setter 'balance' isn't defined for the type 'Account'.
Try correcting the name to the name of an existing setter, or defining a setter or field named 'balance'.
  account.balance = 500;
          ^^^^^^^

The const constructor also acts as a guard: if anyone later adds a non-final field, the class stops compiling (see Common Mistakes).


4. "Changing" an Immutable Object: copyWith

Sara spends 250. The account cannot change, so we build a new account. The common pattern is a method named copyWith: every parameter is optional, and any field you leave out is copied from the original.

dart
class Account {
  final String owner;
  final int balance;

  const Account(this.owner, this.balance);

  Account copyWith({String? owner, int? balance}) {
    return Account(owner ?? this.owner, balance ?? this.balance);
  }
}

void main() {
  const before = Account('Sara', 1000);
  final after = before.copyWith(balance: 750);
  print('Before: ${before.balance}');
  print('After: ${after.balance}');
  print('Owner: ${after.owner}');
}
output
Before: 1000
After: 750
Owner: Sara

owner ?? this.owner means "use the new value if one was given, otherwise keep the old one". Both versions now exist: the old one is untouched, which is exactly what a history or an undo feature needs.


5. final Is Not Deeply Immutable

final locks the field, not the object the field points to. A final list field can still grow:

dart
class Student {
  final String name;
  final List<int> marks;

  Student(this.name, this.marks);
}

void main() {
  final ali = Student('Ali', [80, 75]);
  ali.marks.add(100); // the field is final, the list is not
  print(ali.marks);
}
output
[80, 75, 100]

Worse, the caller still holds the list it passed in, so it can change the student from outside:

dart
void main() {
  final entered = [80, 75];
  final ali = Student('Ali', entered);
  entered.add(0); // changes Ali's marks from outside
  print(ali.marks);
}
output
[80, 75, 0]

A class is deeply immutable only when its fields can't be reassigned and the objects inside them can't change either.


6. The Fix: A Defensive Copy

Make the constructor store its own locked copy with List.unmodifiable (Lecture 046). This is called a defensive copy.

dart
class Student {
  final String name;
  final List<int> marks;

  Student(this.name, List<int> marks) : marks = List.unmodifiable(marks);
}

void main() {
  final entered = [80, 75];
  final ali = Student('Ali', entered);
  entered.add(0);
  print(ali.marks);
  ali.marks.add(100);
}
output
[80, 75]
Unhandled exception:
Unsupported operation: Cannot add to an unmodifiable list

Both leaks are closed: changing entered no longer reaches Ali, and ali.marks refuses changes. The constructor can't be const any more, because it runs code — that is the price. Set.unmodifiable and Map.unmodifiable work the same way.


7. Records Are Shallow Too

Records (Lecture 076) can't be changed field by field, but a record holding a list holds a mutable list:

dart
void main() {
  final entry = ('Zayan', [90, 85]);
  entry.$2.add(0);
  print(entry);
}
output
(Zayan, [90, 85, 0])

The same rule applies: put only immutable values inside a record, or lock the list first.


8. @immutable from package:meta

The meta package (published by the Dart team) provides an @immutable annotation. After dart pub add meta, mark the class, and dart analyze warns if any field is not final:

dart
import 'package:meta/meta.dart';

@immutable
class Account {
  final String owner;
  int balance;

  Account(this.owner, this.balance);
}

void main() {
  print(Account('Ali', 500).balance);
}
output
warning - bin/main.dart:4:7 - This class (or a class that this class inherits from) is marked as '@immutable', but one or more of its instance fields aren't final: Account.balance - must_be_immutable

The program still runs (it prints 500); this is an analyzer warning, not a compile error. Its value is that it also covers subclasses: a child of an @immutable class gets the same check. Like final, it is shallow: it does not look inside a List field.


9. A Real-World Example

A bank account whose history is a locked list, updated only through copyWith:

dart
class Account {
  final String owner;
  final int balance;
  final List<int> history;

  Account(this.owner, this.balance, List<int> history)
    : history = List.unmodifiable(history);

  Account copyWith({int? balance, List<int>? history}) {
    return Account(owner, balance ?? this.balance, history ?? this.history);
  }

  Account deposit(int amount) =>
      copyWith(balance: balance + amount, history: [...history, amount]);
}

void main() {
  final opened = Account('Ali', 0, []);
  final later = opened.deposit(500).deposit(250);
  print('Opened: ${opened.balance} ${opened.history}');
  print('Later: ${later.balance} ${later.history}');
}
output
Opened: 0 []
Later: 750 [500, 250]

deposit never changes this: it returns a new account with a new list built by spread. The opened account is still there, unchanged, and can be passed anywhere without worry.


Code Examples

Example 1 — One List, Two Names

dart
void main() {
  final cart = ['Pen'];
  final sameCart = cart;
  sameCart.add('Book');
  print(cart);
  print(identical(cart, sameCart));
}
output
[Pen, Book]
true

Example 2 — A Real Copy

dart
void main() {
  final cart = ['Pen'];
  final copy = [...cart];
  copy.add('Book');
  print(cart);
  print(copy);
}
output
[Pen]
[Pen, Book]

Example 3 — A Constant Object

dart
class Point {
  final int x;
  final int y;

  const Point(this.x, this.y);
}

void main() {
  const a = Point(1, 2);
  const b = Point(1, 2);
  print(identical(a, b));
}
output
true

Sharing one object is safe here, because neither a nor b can change it.

Example 4 — copyWith

dart
class Point {
  final int x;
  final int y;

  const Point(this.x, this.y);

  Point copyWith({int? x, int? y}) => Point(x ?? this.x, y ?? this.y);
}

void main() {
  const start = Point(1, 2);
  final moved = start.copyWith(y: 9);
  print('${start.x}, ${start.y}');
  print('${moved.x}, ${moved.y}');
}
output
1, 2
1, 9

Example 5 — A Defensive Copy

dart
class Team {
  final List<String> players;

  Team(List<String> players) : players = List.unmodifiable(players);
}

void main() {
  final names = ['Ali', 'Sara'];
  final team = Team(names);
  names.add('Zayan');
  print(team.players);
}
output
[Ali, Sara]

Example 6 — A New Record

dart
void main() {
  const score = (name: 'Sara', points: 10);
  final updated = (name: score.name, points: score.points + 5);
  print(score);
  print(updated);
}
output
(name: Sara, points: 10)
(name: Sara, points: 15)

Example 7 — Student Marks

dart
class Student {
  final String name;
  final List<int> marks;

  Student(this.name, List<int> marks) : marks = List.unmodifiable(marks);

  Student addMark(int mark) => Student(name, [...marks, mark]);

  int get total => marks.fold(0, (sum, m) => sum + m);
}

void main() {
  final term1 = Student('Ali', [80, 75]);
  final term2 = term1.addMark(85);
  print('${term1.marks} total ${term1.total}');
  print('${term2.marks} total ${term2.total}');
}
output
[80, 75] total 155
[80, 75, 85] total 240

How it works

  1. The constructor stores a locked copy of the marks.
  2. addMark builds a new list with spread and a new Student.
  3. term1 keeps its two marks; term2 has three.

Example 8 — Shopping Total

dart
class Item {
  final String name;
  final int price;
  final int quantity;

  const Item(this.name, this.price, this.quantity);

  Item copyWith({int? quantity}) =>
      Item(name, price, quantity ?? this.quantity);

  int get total => price * quantity;
}

void main() {
  const notebook = Item('Notebook', 500, 1);
  final three = notebook.copyWith(quantity: 3);
  print('${notebook.quantity} x ${notebook.price} = ${notebook.total}');
  print('${three.quantity} x ${three.price} = ${three.total}');
}
output
1 x 500 = 500
3 x 500 = 1500

How it works

  1. copyWith only offers quantity: name and price are not meant to change.
  2. The const notebook stays at quantity 1.

Example 9 — Bank Balance

dart
class Account {
  final String owner;
  final int balance;

  const Account(this.owner, this.balance);

  Account withdraw(int amount) => Account(owner, balance - amount);
}

void main() {
  const start = Account('Sara', 1000);
  final states = [start, start.withdraw(200), start.withdraw(200).withdraw(50)];
  for (final s in states) {
    print('${s.owner}: ${s.balance}');
  }
}
output
Sara: 1000
Sara: 800
Sara: 750

How it works

  1. Every withdraw returns a new account, so all three states exist at once.
  2. A mutable account could only show its latest balance.

Example 10 — Temperature Readings

dart
class Reading {
  final String city;
  final double celsius;

  const Reading(this.city, this.celsius);

  Reading copyWith({double? celsius}) => Reading(city, celsius ?? this.celsius);
}

void main() {
  const readings = [Reading('Lahore', 31.5), Reading('Karachi', 29.0)];
  final corrected = [
    for (final r in readings) r.copyWith(celsius: r.celsius + 0.5),
  ];
  print(readings.map((r) => r.celsius).toList());
  print(corrected.map((r) => r.celsius).toList());
}
output
[31.5, 29.0]
[32.0, 29.5]

How it works

  1. A const list of const objects is deeply immutable.
  2. Collection for builds a new list of corrected copies; the originals stay as recorded.

Common Beginner Mistakes

1. Thinking final makes a list unchangeable

dart
final aliMarks = [80, 75, 85];
aliMarks.add(0); // allowed — final locks the variable, not the list

Use const, List.unmodifiable, or a defensive copy when the contents must not change.

2. A non-final field in a const class

dart
class Account {
  final String owner;
  int balance; // ✗

  const Account(this.owner, this.balance);
}
output
Error: Constructor is marked 'const' so all fields must be final.
  const Account(this.owner, this.balance);
        ^

3. Ignoring the result of copyWith

dart
void main() {
  const account = Account('Sara', 1000);
  account.copyWith(balance: 750); // ✗ result thrown away
  print(account.balance);
}
output
1000

copyWith returns a new object. Store it: final after = account.copyWith(balance: 750);.

4. Storing the caller's list directly

Student(this.name, this.marks) keeps the caller's list, so the caller can still change it. Copy it in the initializer list with List.unmodifiable.

5. A function that changes the list it was given

dart
int highest(List<int> marks) {
  marks.sort(); // ✗ reorders the caller's list
  return marks.last;
}

void main() {
  final marks = [80, 95, 75];
  print(highest(marks));
  print(marks);
}
output
95
[75, 80, 95]

Sort a copy instead: final sorted = [...marks]..sort();.

6. Expecting @immutable to stop the program

It is an analyzer warning. dart run still runs the class; only dart analyze reports it.


Best Practices

  • Make data classes immutable by default: all fields final, plus a const constructor when possible.
  • "Change" an object with copyWith or a method that returns a new object.
  • Copy incoming lists, sets, and maps with List.unmodifiable, Set.unmodifiable, or Map.unmodifiable.
  • Don't let a function change a collection it received; return a new one.
  • Prefer const objects and lists for values known while writing the code.
  • Add @immutable to classes meant to stay immutable, so subclasses are checked too.

Mini Challenge

Write an immutable Player with name and score, a const constructor, and an addPoints(int points) method that returns a new Player. Start Zayan at 10, add 5, then 20, and print both the first and the last player.

output
Zayan: 10
Zayan: 35

Try it yourself before opening the solution.

Show solution
dart
class Player {
  final String name;
  final int score;

  const Player(this.name, this.score);

  Player addPoints(int points) => Player(name, score + points);
}

void main() {
  const first = Player('Zayan', 10);
  final last = first.addPoints(5).addPoints(20);
  print('${first.name}: ${first.score}');
  print('${last.name}: ${last.score}');
}
output
Zayan: 10
Zayan: 35

How it works

  1. Both fields are final and the constructor is const.
  2. Each addPoints call returns a new player, so first still has 10.

Practice Questions

Beginner

  1. Create final a = [1, 2]; and final b = a;, add 3 through b, and print a.
  2. Repeat Question 1, but make b a real copy using spread. Print a and b.
  3. Write an immutable City class with a final name and a const constructor. Create const City('Lahore') and print its name.

Application

  1. Give a Product (name, price) class a copyWith. Change a 'Pen' priced 50 to 60 and print both prices.
  2. Make a Basket class whose constructor stores List.unmodifiable(items). Pass ['Milk'], add 'Eggs' to the original list, and print the basket's items.
  3. Create the record (name: 'Ali', age: 20) and build a new record with age 21. Print both.
  4. Write List<int> doubled(List<int> values) that returns a new list and leaves its input unchanged. Call it on [1, 2, 3] and print the input and the result.
  5. Give a Counter class with a final int value an increment() method that returns a new Counter. Start at 0, call it three times in a chain, and print the final value.

Challenge

  1. Write an immutable Cart with an unmodifiable items list and an add(String item) method that returns a new Cart. Start empty, add 'Pen', then 'Book', and print every cart's items.
  2. Write an immutable Thermometer with city and List<double> readings (locked). Add a record(double c) method returning a new Thermometer and an average getter. Start Karachi with [30.0, 32.0], record 34.0, and print both averages.

Practice Answers

Question 1 — Solution

dart
void main() {
  final a = [1, 2];
  final b = a;
  b.add(3);
  print(a);
}
output
[1, 2, 3]

Question 2 — Solution

dart
void main() {
  final a = [1, 2];
  final b = [...a];
  b.add(3);
  print(a);
  print(b);
}
output
[1, 2]
[1, 2, 3]

Question 3 — Solution

dart
class City {
  final String name;

  const City(this.name);
}

void main() {
  const city = City('Lahore');
  print(city.name);
}
output
Lahore

Question 4 — Solution

dart
class Product {
  final String name;
  final int price;

  const Product(this.name, this.price);

  Product copyWith({String? name, int? price}) =>
      Product(name ?? this.name, price ?? this.price);
}

void main() {
  const pen = Product('Pen', 50);
  final dearer = pen.copyWith(price: 60);
  print(pen.price);
  print(dearer.price);
}
output
50
60

Question 5 — Solution

dart
class Basket {
  final List<String> items;

  Basket(List<String> items) : items = List.unmodifiable(items);
}

void main() {
  final list = ['Milk'];
  final basket = Basket(list);
  list.add('Eggs');
  print(basket.items);
}
output
[Milk]

Question 6 — Solution

dart
void main() {
  const ali = (name: 'Ali', age: 20);
  final older = (name: ali.name, age: ali.age + 1);
  print(ali);
  print(older);
}
output
(age: 20, name: Ali)
(age: 21, name: Ali)

Records print their named fields in alphabetical order, not the order you wrote them.

Question 7 — Solution

dart
List<int> doubled(List<int> values) => [for (final v in values) v * 2];

void main() {
  final input = [1, 2, 3];
  print(input);
  print(doubled(input));
}
output
[1, 2, 3]
[2, 4, 6]

Question 8 — Solution

dart
class Counter {
  final int value;

  const Counter(this.value);

  Counter increment() => Counter(value + 1);
}

void main() {
  const start = Counter(0);
  print(start.increment().increment().increment().value);
}
output
3

Question 9 — Solution

dart
class Cart {
  final List<String> items;

  Cart(List<String> items) : items = List.unmodifiable(items);

  Cart add(String item) => Cart([...items, item]);
}

void main() {
  final empty = Cart([]);
  final one = empty.add('Pen');
  final two = one.add('Book');
  print(empty.items);
  print(one.items);
  print(two.items);
}
output
[]
[Pen]
[Pen, Book]

Question 10 — Solution

dart
class Thermometer {
  final String city;
  final List<double> readings;

  Thermometer(this.city, List<double> readings)
    : readings = List.unmodifiable(readings);

  Thermometer record(double c) => Thermometer(city, [...readings, c]);

  double get average => readings.reduce((a, b) => a + b) / readings.length;
}

void main() {
  final before = Thermometer('Karachi', [30.0, 32.0]);
  final after = before.record(34.0);
  print('${before.city}: ${before.average}');
  print('${after.city}: ${after.average}');
}
output
Karachi: 31.0
Karachi: 32.0

Quick Revision

  1. What makes an object immutable?
  2. Why does final b = a; not copy a list?
  3. What two things does an immutable class need?
  4. What does copyWith return, and what happens to fields you leave out?
  5. Why can a final List<int> field still change?
  6. What is a defensive copy?
  7. Is a record holding a list deeply immutable?
  8. What does @immutable check, and when?

Key Takeaways

  • An immutable object never changes, so it can be shared without surprises.
  • Assigning an object to a second variable shares it; spread ([...list]) makes a copy.
  • An immutable class has all fields final and, where possible, a const constructor.
  • copyWith returns a new object with some fields replaced and the rest copied.
  • final is shallow: a final list field can still be changed through the list.
  • A defensive copy with List.unmodifiable makes list fields deeply immutable.
  • Records are shallow too; put only immutable values inside them.
  • @immutable from package:meta makes dart analyze warn about non-final fields, including in subclasses.

What's Next?

Lecture 125 — Code Organization & Documentation. Our immutable classes, their copyWith methods and helper functions are piling up in one file. Next we will split a project into well-named files and folders and document each public class with /// comments, so other developers know exactly how to use it.


Video lecture

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