On this page25 sections
- Prerequisites
- Connection With Previous Lectures
- Learning Objectives
- 1. Catch, Act, Pass On: rethrow
- 2. rethrow vs throw e: The Stack Trace
- 3. Wrapping a Low-Level Exception
- 4. Keeping the Original Trace: Error.throwWithStackTrace
- 5. Rule 1 — Throw Early
- 6. Rule 2 — Catch Where You Can Act
- 7. Rule 3 — Never Swallow an Exception
- 8. Rule 4 — Specific Types, Messages with Context
- 9. An Alternative: Sealed Result Types
- 10. assert: Checks for Developers
- 11. assert in an Initializer List
- 12. assert or Exception?
- 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 86 — Rethrowing, Exception Design & Assertions
| Category | 14 — Error & Exception Handling |
| Lecture | 086 of 140 |
| Estimated video length | 17 min |
| Target | Dart 3.12.2 |
| Level | Intermediate |
You can throw, catch, and define your own exceptions. Now you decide where each one should be thrown, caught, or passed on, and you add
assertchecks that catch your own mistakes while you develop.
Contents
Prerequisites · Connection · Objectives · Lesson · Code Examples · Common Mistakes · Best Practices · Mini Challenge · Practice Questions · Answers · Takeaways
Every error, warning and stack trace in this lecture is real. Errors come from
dart run, lint messages fromdart analyze, on Dart 3.12.2. Long file paths are shortened to…, and the extra stack-trace lines after a failed assertion are left out.
Prerequisites
Students should already understand, from Lectures 054–085:
- constructors and initializer lists (Lecture 054)
- generic classes (Lecture 073) and switch expressions on patterns (Lecture 081)
- sealed classes and exhaustive switches over them (Lecture 082)
- the difference between an
Errorand anException(Lecture 083) try,on,catch (e, s), andfinally(Lecture 084)throwand custom exception classes (Lecture 085)
Connection With Previous Lectures
We can now throw and catch exceptions, but real programs have layers: a small function reads
a number, a bigger one builds a report, main() talks to the user. When the small function
fails, which layer should catch it? Can a layer note the problem and still pass it upward?
And how do we catch bugs in our own code, not just bad input?
Learning Objectives
By the end of this lecture you will be able to:
- Use
rethrowto pass on an exception with its original stack trace - Explain why
throw eloses where the problem started - Wrap a low-level exception in a domain exception, with
Error.throwWithStackTraceif needed - Apply the design rules: throw early, catch where you can act, never swallow
- Catch specific types and write messages that carry context
- Return a sealed
Resulttype instead of throwing, when failure is an expected outcome - Write
assertchecks and run them withdart run --enable-asserts - Put
assertin a constructor's initializer list
1. Catch, Act, Pass On: rethrow
Sometimes a function wants to notice an exception — to log it — while the caller still
decides what to do. Inside a catch block, rethrow throws the same exception on upward.
Analogy — a sorting-office clerk notes a damaged parcel in the log book, then sends the same parcel on, original label and all, to the office that can deal with it.
int parseMarks(String text) {
var marks = int.tryParse(text);
if (marks == null) {
throw FormatException('Not a number: "$text"');
}
return marks;
}
int loadMarks(String text) {
try {
return parseMarks(text);
} catch (e) {
print('log: could not read "$text"');
rethrow;
}
}
void main() {
try {
loadMarks('eighty');
} on FormatException catch (e) {
print('main: $e');
}
}log: could not read "eighty"
main: FormatException: Not a number: "eighty"loadMarks logged it; main() received it unchanged.
2. rethrow vs throw e: The Stack Trace
throw e; throws the same exception, but with a new stack trace starting at the catch,
so it no longer shows where the problem began. firstFrame prints a trace's first line:
String firstFrame(StackTrace s) =>
s.toString().split('\n').first.split(' (').first;
int parseMarks(String text) =>
int.tryParse(text) ?? (throw FormatException('Not a number: "$text"'));
int withRethrow(String text) {
try {
return parseMarks(text);
} catch (e) {
rethrow;
}
}
int withThrowE(String text) {
try {
return parseMarks(text);
} catch (e) {
throw e;
}
}
void main() {
for (var load in [withRethrow, withThrowE]) {
try {
load('abc');
} catch (e, s) {
print(firstFrame(s));
}
}
}#0 parseMarks
#0 withThrowErethrow still points at parseMarks, the real culprit. dart analyze flags throw e:
info - Use 'rethrow' to rethrow a caught exception. Try replacing the 'throw' with a
'rethrow'. - use_rethrow_when_possible3. Wrapping a Low-Level Exception
A FormatException from deep inside means little to the code that asked for a report.
Throw a domain exception instead — one in your program's own language — keeping the
original as its cause:
class ReportException implements Exception {
final String message;
final Object? cause;
ReportException(this.message, {this.cause});
@override
String toString() => 'ReportException: $message';
}
int loadMarks(String student, String text) {
try {
return int.parse(text);
} on FormatException catch (e) {
throw ReportException('Marks for $student are unreadable', cause: e);
}
}
void main() {
try {
loadMarks('Sara', '9O');
} on ReportException catch (e) {
print(e);
print('cause: ${e.cause}');
}
}ReportException: Marks for Sara are unreadable
cause: FormatException: Invalid radix-10 number (at character 1)
9O
^Callers catch ReportException; the details stay available for debugging.
4. Keeping the Original Trace: Error.throwWithStackTrace
A wrapped exception gets a new stack trace that starts at the catch. To throw the new
exception with the old trace, use Error.throwWithStackTrace(exception, stackTrace):
String firstFrame(StackTrace s) =>
s.toString().split('\n').first.split(' (').first;
int parseMarks(String text) =>
int.tryParse(text) ?? (throw FormatException('Not a number: "$text"'));
int loadMarks(String student, String text) {
try {
return parseMarks(text);
} on FormatException catch (e, s) {
Error.throwWithStackTrace(Exception('$student: ${e.message}'), s);
}
}
void main() {
try {
loadMarks('Zayan', 'n/a');
} catch (e, s) {
print(e);
print(firstFrame(s));
}
}Exception: Zayan: Not a number: "n/a"
#0 parseMarksIts return type is Never (Lecture 075), so Dart knows loadMarks never falls off the end.
5. Rule 1 — Throw Early
Check inputs at the start of a function, before anything changes. A half-done operation is harder to undo than one that never started:
class InsufficientFundsException implements Exception {
final String message;
InsufficientFundsException(this.message);
}
class Account {
double balance;
Account(this.balance);
void withdraw(double amount) {
if (amount <= 0) {
throw ArgumentError.value(amount, 'amount', 'must be positive');
}
if (amount > balance) {
throw InsufficientFundsException('balance $balance is less than $amount');
}
balance -= amount;
}
}
void main() {
var account = Account(1000);
try {
account.withdraw(1500);
} on InsufficientFundsException catch (e) {
print(e.message);
}
print('Balance: ${account.balance}');
}balance 1000.0 is less than 1500.0
Balance: 1000.0A negative amount is a caller's bug: ArgumentError. Too little money is a normal
situation to handle: an Exception (Lecture 083).
6. Rule 2 — Catch Where You Can Act
Catch an exception only where you can do something useful: show a message, skip an item,
try again. parseMarks just throws; the loop in main() decides to skip and report:
int parseMarks(String text) {
var marks = int.tryParse(text);
if (marks == null) {
throw FormatException('Not a number: "$text"');
}
return marks;
}
void main() {
var lines = ['80', 'x', '70'];
var total = 0;
for (var line in lines) {
try {
total += parseMarks(line);
} on FormatException catch (e) {
print('Skipped: ${e.message}');
}
}
print('Total: $total');
}Skipped: Not a number: "x"
Total: 1507. Rule 3 — Never Swallow an Exception
An empty catch swallows the exception. Replace the catch in section 6 with this:
// } catch (e) {} // ✗ swallowedIt still prints Total: 150, but now nobody knows a line was lost, and the analyzer warns:
info - Empty catch block. Try adding statements to the block, adding a comment to the
block, or removing the 'catch' clause. - empty_catchesIf ignoring really is correct, say why in a comment inside the block.
8. Rule 4 — Specific Types, Messages with Context
A bare catch (e) catches everything, including bugs. Here the code reads parts[2]
instead of parts[1], and the catch-all reports a "bad number":
void main() {
var parts = ['Ali', '80'];
try {
print(int.parse(parts[2])); // bug: should be parts[1]
} catch (e) {
print('Invalid number');
}
}Invalid numberWith on FormatException the RangeError would surface at once. Messages need context,
too: 'Not a number: "x"' beats 'Invalid input'. Include the value and what was expected.
9. An Alternative: Sealed Result Types
When failure is an expected outcome, like letters typed into a marks field, return it instead of throwing. A sealed class (Lecture 082) describes both outcomes:
sealed class Result<T> {}
class Ok<T> extends Result<T> {
final T value;
Ok(this.value);
}
class Failure<T> extends Result<T> {
final String message;
Failure(this.message);
}
Result<int> parseMarks(String text) {
var marks = int.tryParse(text);
if (marks == null) return Failure('Not a number: "$text"');
if (marks < 0 || marks > 100) return Failure('Out of range: $marks');
return Ok(marks);
}
void main() {
for (var text in ['85', 'abc', '120']) {
var message = switch (parseMarks(text)) {
Ok(:var value) => 'Marks: $value',
Failure(:var message) => 'Problem: $message',
};
print(message);
}
}Marks: 85
Problem: Not a number: "abc"
Problem: Out of range: 120Forgetting the Failure arm is a compile error. Use exceptions for the unexpected; use Result when failure is one
of the normal answers.
10. assert: Checks for Developers
assert(condition, message) stops the program if condition is false. Use it for facts
that are always true if your code is correct:
double average(List<int> marks) {
assert(marks.isNotEmpty, 'average() needs at least one mark');
var total = 0;
for (var m in marks) {
total += m;
}
return total / marks.length;
}
void main() {
print(average([80, 90]));
print(average([]));
}The catch: assertions are switched off by default. A plain dart run skips them:
$ dart run main.dart
85.0
NaNTurn them on with --enable-asserts:
$ dart run --enable-asserts main.dart
85.0
Unhandled exception:
'file:///…/main.dart': Failed assertion: line 2 pos 10: 'marks.isNotEmpty': average() needs at least one markA failed assert throws an AssertionError — an Error, not meant to be caught. Tests
(Lecture 118 onward) run with assertions on.
11. assert in an Initializer List
In Lecture 054 we met : assert(price >= 0) in a constructor. The initializer list runs
before the object exists, so a bad value is stopped at the door:
class Product {
final String name;
final double price;
Product(this.name, this.price)
: assert(name.isNotEmpty, 'name must not be empty'),
assert(price >= 0, 'price must not be negative, got $price');
}
void main() {
var pen = Product('Pen', -50);
print('${pen.name}: ${pen.price}');
}$ dart run main.dart
Pen: -50.0
$ dart run --enable-asserts main.dart
Unhandled exception:
'file:///…/main.dart': Failed assertion: line 7 pos 14: 'price >= 0': price must not be negative, got -50.0Separate several asserts with commas.
12. assert or Exception?
Because assertions vanish without the flag, they are never real validation:
| Situation | Use |
|---|---|
| Bad input from a user, file or network | throw an Exception (or return a Result) |
| A caller passes a wrong argument to a public function | throw ArgumentError |
| "This can't happen if my code is right" | assert |
13. A Real-World Example
Ali's bank app transfers money. Every rule from this lecture appears once:
class TransferException implements Exception {
final String message;
TransferException(this.message);
}
class Account {
final String owner;
double balance;
Account(this.owner, this.balance)
: assert(owner.isNotEmpty, 'owner must not be empty');
void withdraw(double amount) {
if (amount > balance) {
throw StateError('$owner has $balance, needs $amount');
}
balance -= amount;
}
}
void transfer(Account from, Account to, double amount) {
if (amount <= 0) {
throw ArgumentError.value(amount, 'amount', 'must be positive');
}
try {
from.withdraw(amount);
to.balance += amount;
} on StateError catch (e, s) {
print('log: ${e.message}');
Error.throwWithStackTrace(
TransferException('${from.owner} → ${to.owner} failed'),
s,
);
}
}
void main() {
var ali = Account('Ali', 500);
var sara = Account('Sara', 200);
for (var amount in [300.0, 400.0]) {
try {
transfer(ali, sara, amount);
print('Sent $amount. Ali: ${ali.balance}, Sara: ${sara.balance}');
} on TransferException catch (e) {
print('Sorry: ${e.message}');
}
}
}Sent 300.0. Ali: 200.0, Sara: 500.0
log: Ali has 200.0, needs 400.0
Sorry: Ali → Sara failedAn assert guards the constructor, transfer throws early, the StateError is logged and
wrapped with its trace, and main() catches only what it can act on.
Code Examples
Example 1 — Log and Rethrow
void main() {
try {
try {
int.parse('12a');
} catch (e) {
print('inner: noted');
rethrow;
}
} on FormatException {
print('outer: handled');
}
}inner: noted
outer: handledExample 2 — Handle Some, Rethrow the Rest
int readQuantity(String text) {
try {
return int.parse(text);
} on FormatException {
if (text.isEmpty) return 0;
rethrow;
}
}
void main() {
print(readQuantity(''));
try {
readQuantity('two');
} on FormatException {
print('"two" is not a quantity');
}
}0
"two" is not a quantityExample 3 — Wrapping in a Domain Exception
class ConfigException implements Exception {
final String message;
ConfigException(this.message);
}
int readPort(String raw) {
try {
return int.parse(raw);
} on FormatException {
throw ConfigException('port "$raw" is not a number');
}
}
void main() {
try {
readPort('eighty');
} on ConfigException catch (e) {
print(e.message);
}
}port "eighty" is not a numberExample 4 — Error.throwWithStackTrace
void main() {
try {
try {
throw FormatException('bad data');
} on FormatException catch (e, s) {
Error.throwWithStackTrace(Exception('Import failed: ${e.message}'), s);
}
} catch (e) {
print(e);
}
}Exception: Import failed: bad dataExample 5 — An Assertion with a Message
void main() {
var items = <String>[];
assert(items.isNotEmpty, 'cart must not be empty');
print('Checking out ${items.length} items');
}$ dart run main.dart
Checking out 0 items
$ dart run --enable-asserts main.dart
Unhandled exception:
'file:///…/main.dart': Failed assertion: line 3 pos 10: 'items.isNotEmpty': cart must not be emptyExample 6 — Assert in an Initializer List
class Temperature {
final double kelvin;
Temperature(this.kelvin) : assert(kelvin >= 0, 'below absolute zero');
}
void main() {
var t = Temperature(300);
print('${t.kelvin} K');
}300.0 KA valid value passes in both modes.
Example 7 — Student Marks: Specific Types
String grade(int marks) {
if (marks < 0 || marks > 100) {
throw RangeError.range(marks, 0, 100, 'marks');
}
return marks >= 50 ? 'Pass' : 'Fail';
}
void main() {
for (var m in [72, 140]) {
try {
print('$m → ${grade(m)}');
} on RangeError catch (e) {
print(e);
}
}
}72 → Pass
RangeError (marks): Invalid value: Not in inclusive range 0..100: 140Example 8 — Shopping Total, Skipping Bad Lines
void main() {
var prices = {'Pen': '50', 'Bag': 'free', 'Book': '400'};
var total = 0;
prices.forEach((item, price) {
try {
total += int.parse(price);
} on FormatException {
print('Skipped $item: "$price" is not a price');
}
});
print('Total: $total');
}Skipped Bag: "free" is not a price
Total: 450Example 9 — Bank Balance: Throw Early
void deposit(List<double> history, double amount) {
if (amount <= 0) {
throw ArgumentError.value(amount, 'amount', 'must be positive');
}
history.add(amount);
}
void main() {
var history = <double>[];
deposit(history, 500);
try {
deposit(history, -20);
} on ArgumentError catch (e) {
print(e);
}
print(history);
}Invalid argument (amount): must be positive: -20.0
[500.0]The bad deposit never reached the list. (Catching an Error is only for this demo.)
Example 10 — Temperature Sensor: Wrap and Report
class SensorException implements Exception {
final String message;
SensorException(this.message);
}
double readCelsius(String raw) {
try {
return double.parse(raw);
} on FormatException {
throw SensorException('sensor sent "$raw" instead of a number');
}
}
void main() {
for (var raw in ['21.5', 'ERR']) {
try {
print('${readCelsius(raw)} °C');
} on SensorException catch (e) {
print(e.message);
}
}
}21.5 °C
sensor sent "ERR" instead of a numberCommon Beginner Mistakes
1. rethrow outside a catch
// void check(int marks) {
// if (marks < 0) rethrow; // ✗
// }Error: 'rethrow' can only be used in catch clauses.Outside a catch, use throw with a new exception.
2. throw e instead of rethrow — the trace loses its origin (section 2).
3. An empty catch — the result is silently wrong (section 7).
4. A bare catch (e) around everything — it hides bugs like a RangeError (section 8).
5. Using assert for user input — a plain dart run skips it (section 11).
Best Practices
- Pass a caught exception on with
rethrow, neverthrow e. - Never leave a
catchempty without a comment explaining why. - Put the bad value and the expectation in every message.
- Return a sealed
Resultwhen failure is a normal answer. - Develop with
--enable-assertson.
Mini Challenge
Write int parseAge(String text) that wraps a FormatException in an
AgeException('Age "<text>" is not a number') and asserts the age is not negative. In
main(), try '25' and 'old'.
Age: 25
Age "old" is not a numberTry it yourself before opening the solution.
Show solution
class AgeException implements Exception {
final String message;
AgeException(this.message);
}
int parseAge(String text) {
try {
var age = int.parse(text);
assert(age >= 0, 'age must not be negative');
return age;
} on FormatException {
throw AgeException('Age "$text" is not a number');
}
}
void main() {
for (var text in ['25', 'old']) {
try {
print('Age: ${parseAge(text)}');
} on AgeException catch (e) {
print(e.message);
}
}
}Age: 25
Age "old" is not a numberPractice Questions
They run from beginner to application to challenge.
Beginner
- Write a
try-catcharoundint.parse('x')that printsLoggingand then usesrethrow. Wrap it in an outertrythat printsCaught outside. - Write
assert(1 > 2, 'math is broken');followed byprint('Done');. What doesdart runprint? What doesdart run --enable-assertsprint first? - Give a class
Boxa fieldfinal int sizeand a constructor withassert(size > 0)in its initializer list. CreateBox(3)and print its size.
Application
- Write
double parsePrice(String text)that turns aFormatExceptionintoPriceException('Price "<text>" is invalid'). Call it with'12.5'and'abc'. - Store
marks = ['90', '', '75', 'A']. Sum them, skipping bad entries with a messageSkipped: "<value>"for each, then printTotal: 165. - Choose
assert,ArgumentError, or anExceptionfor each: (a) a user types-5as a quantity; (b) a private helper is called with an empty list, which your code never does; (c) a publicwithdraw(amount)is called withamount = 0. - Using the
Result,OkandFailureclasses from section 9, writeResult<double> divide(int a, int b)returningFailure('Cannot divide by zero')whenb == 0. Print both outcomes fordivide(10, 4)anddivide(1, 0).
Challenge
-
What does this print, and what does
dart analyzereport?void main() { var total = 0; for (var t in ['5', 'five', '10']) { try { total += int.parse(t); } catch (e) {} } print(total); } -
Rewrite question 4 so the
PriceExceptionkeeps theFormatException's stack trace. -
Sara's score keeper:
int addScore(int score, String points)addspointstoscore, treats emptypointsas0, and rethrows any otherFormatException. Inmain(), start at10and add'5','','x', printingIgnored "x", thenScore: 15.
Practice Answers
Question 1 — Solution
void main() {
try {
try {
int.parse('x');
} catch (e) {
print('Logging');
rethrow;
}
} catch (e) {
print('Caught outside');
}
}Logging
Caught outsideQuestion 2 — Solution
void main() {
assert(1 > 2, 'math is broken');
print('Done');
}$ dart run main.dart
Done
$ dart run --enable-asserts main.dart
Unhandled exception:
'file:///…/main.dart': Failed assertion: line 2 pos 10: '1 > 2': math is brokenQuestion 3 — Solution
class Box {
final int size;
Box(this.size) : assert(size > 0);
}
void main() {
print(Box(3).size);
}3Question 4 — Solution
class PriceException implements Exception {
final String message;
PriceException(this.message);
}
double parsePrice(String text) {
try {
return double.parse(text);
} on FormatException {
throw PriceException('Price "$text" is invalid');
}
}
void main() {
for (var text in ['12.5', 'abc']) {
try {
print(parsePrice(text));
} on PriceException catch (e) {
print(e.message);
}
}
}12.5
Price "abc" is invalidQuestion 5 — Solution
void main() {
var marks = ['90', '', '75', 'A'];
var total = 0;
for (var m in marks) {
try {
total += int.parse(m);
} on FormatException {
print('Skipped: "$m"');
}
}
print('Total: $total');
}Skipped: ""
Skipped: "A"
Total: 165Question 6 — Solution
(a) an Exception (or a Result): bad user input is expected. (b) assert: it can only
happen through your own bug. (c) ArgumentError: the caller broke the function's rules.
Question 7 — Solution
Add the three classes from section 9 above this code.
Result<double> divide(int a, int b) {
if (b == 0) return Failure('Cannot divide by zero');
return Ok(a / b);
}
void main() {
for (var r in [divide(10, 4), divide(1, 0)]) {
print(switch (r) {
Ok(:var value) => 'Result: $value',
Failure(:var message) => 'Error: $message',
});
}
}Result: 2.5
Error: Cannot divide by zeroQuestion 8 — Solution
15The empty catch swallows the FormatException for 'five', so nobody learns a value was
lost. dart analyze reports Empty catch block … - empty_catches.
Question 9 — Solution
Only parsePrice changes; the class and main() stay as in Question 4.
double parsePrice(String text) {
try {
return double.parse(text);
} on FormatException catch (e, s) {
Error.throwWithStackTrace(PriceException('Price "$text" is invalid'), s);
}
}12.5
Price "abc" is invalidSame output, but the stack trace now begins inside double.parse, where the bad text was
found.
Question 10 — Solution
int addScore(int score, String points) {
try {
return score + int.parse(points);
} on FormatException {
if (points.isEmpty) return score;
rethrow;
}
}
void main() {
var score = 10;
for (var p in ['5', '', 'x']) {
try {
score = addScore(score, p);
} on FormatException {
print('Ignored "$p"');
}
}
print('Score: $score');
}Ignored "x"
Score: 15Quick Revision
- What does
rethrowdo, and where can it be written? - How does the stack trace differ between
rethrowandthrow e? - Why wrap a
FormatExceptionin a domain exception? - What does
Error.throwWithStackTracekeep that a plainthrowloses? - What are the four exception design rules from this lecture?
- When is a sealed
Resultbetter than throwing?
Key Takeaways
rethrowkeeps the original stack trace;throw estarts a new one at thecatch.- Wrap low-level exceptions in domain exceptions, keeping the original as a
causeor its trace withError.throwWithStackTrace. - Throw early, catch where you can act, never swallow, catch specific types, and write messages with context.
- A sealed
Resultmakes an expected failure part of the return type. assertruns only with--enable-asserts; it catches the developer's mistakes, never the user's — also in initializer lists.
What's Next?
Lecture 087 — Synchronous vs Asynchronous Programming. That closes error handling. So far every line waited for the one before it. Category 15 asks what happens when a task, like waiting for a server, takes seconds, and how Dart keeps working meanwhile.