On this page21 sections
- Prerequisites
- Connection With Previous Lectures
- Learning Objectives
- 1. What Immutability Means
- 2. Two Names, One Object
- 3. An Immutable Class
- 4. "Changing" an Immutable Object: copyWith
- 5. final Is Not Deeply Immutable
- 6. The Fix: A Defensive Copy
- 7. Records Are Shallow Too
- 8. @immutable from package:meta
- 9. 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 124 — Immutability
| Category | 21 — Professional Dart Development |
| Lecture | 124 of 140 |
| Estimated video length | 8 min |
| Target | Dart 3.12.2 |
| Level | Advanced |
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:
finalandconst(Lecture 006)- spread,
List.unmodifiable, andconstlists (Lecture 046) finalfields andconstconstructors (Lectures 053–055)- records and why they are immutable (Lecture 076)
- adding a package such as
metatopubspec.yaml(Lectures 103–105) dart analyzeand 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:
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);
}[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
finalfields and aconstconstructor - Use the
copyWithpattern to get a changed copy of an object - Explain the difference between
finaland deeply immutable - Protect list fields with
List.unmodifiableand defensive copies - Use
@immutablefrompackage:metato 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.
void main() {
const name = 'Ali';
final upper = name.toUpperCase();
print(name);
print(upper);
}Ali
ALItoUpperCase() 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:
void main() {
final aliMarks = [80, 75, 85];
final report = aliMarks;
print(identical(aliMarks, report));
}trueThere 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:
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}');
}Sara: 1000A final field has no setter, so trying to assign to it is a compile error:
void main() {
const account = Account('Sara', 1000);
account.balance = 500; // ✗
}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.
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}');
}Before: 1000
After: 750
Owner: Saraowner ?? 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:
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);
}[80, 75, 100]Worse, the caller still holds the list it passed in, so it can change the student from outside:
void main() {
final entered = [80, 75];
final ali = Student('Ali', entered);
entered.add(0); // changes Ali's marks from outside
print(ali.marks);
}[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.
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);
}[80, 75]
Unhandled exception:
Unsupported operation: Cannot add to an unmodifiable listBoth 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:
void main() {
final entry = ('Zayan', [90, 85]);
entry.$2.add(0);
print(entry);
}(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:
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);
}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_immutableThe 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:
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}');
}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
void main() {
final cart = ['Pen'];
final sameCart = cart;
sameCart.add('Book');
print(cart);
print(identical(cart, sameCart));
}[Pen, Book]
trueExample 2 — A Real Copy
void main() {
final cart = ['Pen'];
final copy = [...cart];
copy.add('Book');
print(cart);
print(copy);
}[Pen]
[Pen, Book]Example 3 — A Constant Object
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));
}trueSharing one object is safe here, because neither a nor b can change it.
Example 4 — copyWith
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}');
}1, 2
1, 9Example 5 — A Defensive Copy
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);
}[Ali, Sara]Example 6 — A New Record
void main() {
const score = (name: 'Sara', points: 10);
final updated = (name: score.name, points: score.points + 5);
print(score);
print(updated);
}(name: Sara, points: 10)
(name: Sara, points: 15)Example 7 — Student Marks
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}');
}[80, 75] total 155
[80, 75, 85] total 240How it works
- The constructor stores a locked copy of the marks.
addMarkbuilds a new list with spread and a newStudent.term1keeps its two marks;term2has three.
Example 8 — Shopping Total
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}');
}1 x 500 = 500
3 x 500 = 1500How it works
copyWithonly offersquantity: name and price are not meant to change.- The
constnotebook stays at quantity1.
Example 9 — Bank Balance
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}');
}
}Sara: 1000
Sara: 800
Sara: 750How it works
- Every
withdrawreturns a new account, so all three states exist at once. - A mutable account could only show its latest balance.
Example 10 — Temperature Readings
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());
}[31.5, 29.0]
[32.0, 29.5]How it works
- A
constlist ofconstobjects is deeply immutable. - Collection
forbuilds a new list of corrected copies; the originals stay as recorded.
Common Beginner Mistakes
1. Thinking final makes a list unchangeable
final aliMarks = [80, 75, 85];
aliMarks.add(0); // allowed — final locks the variable, not the listUse const, List.unmodifiable, or a defensive copy when the contents must not change.
2. A non-final field in a const class
class Account {
final String owner;
int balance; // ✗
const Account(this.owner, this.balance);
}Error: Constructor is marked 'const' so all fields must be final.
const Account(this.owner, this.balance);
^3. Ignoring the result of copyWith
void main() {
const account = Account('Sara', 1000);
account.copyWith(balance: 750); // ✗ result thrown away
print(account.balance);
}1000copyWith 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
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);
}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 aconstconstructor when possible. - "Change" an object with
copyWithor a method that returns a new object. - Copy incoming lists, sets, and maps with
List.unmodifiable,Set.unmodifiable, orMap.unmodifiable. - Don't let a function change a collection it received; return a new one.
- Prefer
constobjects and lists for values known while writing the code. - Add
@immutableto 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.
Zayan: 10
Zayan: 35Try it yourself before opening the solution.
Show solution
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}');
}Zayan: 10
Zayan: 35How it works
- Both fields are
finaland the constructor isconst. - Each
addPointscall returns a new player, sofirststill has10.
Practice Questions
Beginner
- Create
final a = [1, 2];andfinal b = a;, add3throughb, and printa. - Repeat Question 1, but make
ba real copy using spread. Printaandb. - Write an immutable
Cityclass with afinalnameand aconstconstructor. Createconst City('Lahore')and print its name.
Application
- Give a
Product(name,price) class acopyWith. Change a'Pen'priced50to60and print both prices. - Make a
Basketclass whose constructor storesList.unmodifiable(items). Pass['Milk'], add'Eggs'to the original list, and print the basket's items. - Create the record
(name: 'Ali', age: 20)and build a new record with age21. Print both. - 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. - Give a
Counterclass with afinal int valueanincrement()method that returns a newCounter. Start at0, call it three times in a chain, and print the final value.
Challenge
- Write an immutable
Cartwith an unmodifiableitemslist and anadd(String item)method that returns a newCart. Start empty, add'Pen', then'Book', and print every cart's items. - Write an immutable
ThermometerwithcityandList<double> readings(locked). Add arecord(double c)method returning a newThermometerand anaveragegetter. Start Karachi with[30.0, 32.0], record34.0, and print both averages.
Practice Answers
Question 1 — Solution
void main() {
final a = [1, 2];
final b = a;
b.add(3);
print(a);
}[1, 2, 3]Question 2 — Solution
void main() {
final a = [1, 2];
final b = [...a];
b.add(3);
print(a);
print(b);
}[1, 2]
[1, 2, 3]Question 3 — Solution
class City {
final String name;
const City(this.name);
}
void main() {
const city = City('Lahore');
print(city.name);
}LahoreQuestion 4 — Solution
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);
}50
60Question 5 — Solution
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);
}[Milk]Question 6 — Solution
void main() {
const ali = (name: 'Ali', age: 20);
final older = (name: ali.name, age: ali.age + 1);
print(ali);
print(older);
}(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
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));
}[1, 2, 3]
[2, 4, 6]Question 8 — Solution
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);
}3Question 9 — Solution
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);
}[]
[Pen]
[Pen, Book]Question 10 — Solution
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}');
}Karachi: 31.0
Karachi: 32.0Quick Revision
- What makes an object immutable?
- Why does
final b = a;not copy a list? - What two things does an immutable class need?
- What does
copyWithreturn, and what happens to fields you leave out? - Why can a
final List<int>field still change? - What is a defensive copy?
- Is a record holding a list deeply immutable?
- What does
@immutablecheck, 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
finaland, where possible, aconstconstructor. copyWithreturns a new object with some fields replaced and the rest copied.finalis shallow: afinallist field can still be changed through the list.- A defensive copy with
List.unmodifiablemakes list fields deeply immutable. - Records are shallow too; put only immutable values inside them.
@immutablefrompackage:metamakesdart analyzewarn about non-finalfields, 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.