Error Handling & Exceptions
What Are Exceptions?
An exception is an event that disrupts the normal flow of your program. When something goes wrong -- like dividing by zero, accessing an invalid index, or trying to open a file that does not exist -- Dart creates an exception object that describes the problem. If you do not handle (catch) that exception, your program will crash.
Think of exceptions as alarm bells. When something unexpected happens, the alarm goes off. You can either let the alarm stop your entire program, or you can intercept it, deal with the problem, and keep going.
Try, Catch, and Finally
The most fundamental error handling structure in Dart is the try-catch-finally block. It lets you attempt risky code, catch any problems, and optionally run cleanup code regardless of whether an error occurred.
Basic Try-Catch
void main() {
try {
int result = 10 ~/ 0; // Integer division by zero
print(result);
} catch (e) {
print('An error occurred: $e');
}
print('Program continues running!');
}
// Output:
// An error occurred: IntegerDivisionByZeroException
// Program continues running!
Without the try-catch, the program would have crashed at the division line. With it, we gracefully handle the error and continue execution.
The finally Block
The finally block always executes, whether or not an exception was thrown. This is perfect for cleanup tasks like closing files, database connections, or releasing resources.
Try-Catch-Finally
void main() {
try {
print('Opening resource...');
var result = int.parse('not_a_number');
print('Result: $result');
} catch (e) {
print('Error: $e');
} finally {
print('Cleanup: closing resource.');
}
}
// Output:
// Opening resource...
// Error: FormatException: not_a_number
// Cleanup: closing resource.
finally block runs even if you use return inside try or catch. It is guaranteed to execute, making it the ideal place for cleanup code.Catching Specific Exception Types
Instead of catching all exceptions with a generic catch, you can target specific exception types using the on keyword. This lets you respond differently to different kinds of errors.
Catching Specific Types with on
void main() {
try {
var value = int.parse('hello');
} on FormatException {
print('That is not a valid number format!');
} on RangeError {
print('Value is out of range!');
} catch (e) {
print('Something else went wrong: $e');
}
}
// Output:
// That is not a valid number format!
The on Keyword vs the catch Keyword
Dart gives you two mechanisms that can be used separately or together:
on ExceptionType-- matches a specific exception type but does not give you access to the exception object.catch (e)-- catches any exception and gives you the exception objecte.on ExceptionType catch (e)-- matches a specific type AND gives you the object.
Combining on and catch
void main() {
try {
List<int> numbers = [1, 2, 3];
print(numbers[10]); // Index out of range
} on RangeError catch (e) {
print('RangeError caught!');
print('Message: ${e.message}');
}
}
// Output:
// RangeError caught!
// Message: Index out of range
Accessing the Stack Trace
You can capture the stack trace as a second parameter in the catch block. The stack trace shows you exactly where the error occurred in your code.
Catching with Stack Trace
void main() {
try {
riskyFunction();
} catch (e, stackTrace) {
print('Error: $e');
print('Stack trace:\n$stackTrace');
}
}
void riskyFunction() {
throw Exception('Something went wrong in riskyFunction');
}
The Exception Class
Dart’s Exception class is the base class for all exceptions that programs should catch. Some common built-in exceptions include:
FormatException-- thrown when a string cannot be parsed (e.g.,int.parse('abc'))IOException-- thrown for input/output failuresHttpException-- thrown for HTTP-related errorsIntegerDivisionByZeroException-- thrown when dividing an integer by zeroStateError-- thrown when an object is in an invalid stateArgumentError-- thrown when a function receives an invalid argumentRangeError-- thrown when an index is out of bounds
Common Built-in Exceptions
void main() {
// FormatException
try {
int.parse('abc');
} on FormatException catch (e) {
print('Format error: $e');
}
// RangeError
try {
var list = [1, 2, 3];
print(list[5]);
} on RangeError catch (e) {
print('Range error: $e');
}
// StateError
try {
var emptyList = <int>[];
emptyList.first; // No element
} on StateError catch (e) {
print('State error: $e');
}
}
Throwing Exceptions
You can throw your own exceptions using the throw keyword. You can throw any object in Dart, but it is best practice to throw objects that implement Exception or Error.
Throwing an Exception
double divide(double a, double b) {
if (b == 0) {
throw ArgumentError('Cannot divide by zero');
}
return a / b;
}
void main() {
try {
var result = divide(10, 0);
print(result);
} on ArgumentError catch (e) {
print('Error: ${e.message}');
}
}
// Output:
// Error: Cannot divide by zero
Throwing Exception with a Message
void validateAge(int age) {
if (age < 0) {
throw Exception('Age cannot be negative: $age');
}
if (age > 150) {
throw Exception('Age is unrealistic: $age');
}
print('Valid age: $age');
}
void main() {
try {
validateAge(-5);
} catch (e) {
print(e); // Exception: Age cannot be negative: -5
}
}
Creating Custom Exceptions
For complex applications, you should create your own exception classes. This makes your error handling more precise and meaningful.
Custom Exception Classes
class InsufficientFundsException implements Exception {
final double amount;
final double balance;
InsufficientFundsException(this.amount, this.balance);
@override
String toString() =>
'InsufficientFundsException: Tried to withdraw \$$amount '
'but only \$$balance is available.';
}
class AccountLockedException implements Exception {
final String reason;
AccountLockedException(this.reason);
@override
String toString() => 'AccountLockedException: $reason';
}
class BankAccount {
double balance;
bool isLocked;
BankAccount(this.balance, {this.isLocked = false});
void withdraw(double amount) {
if (isLocked) {
throw AccountLockedException('Account is locked for security.');
}
if (amount > balance) {
throw InsufficientFundsException(amount, balance);
}
balance -= amount;
print('Withdrawn \$$amount. New balance: \$$balance');
}
}
void main() {
var account = BankAccount(100.0);
try {
account.withdraw(150.0);
} on InsufficientFundsException catch (e) {
print(e);
} on AccountLockedException catch (e) {
print(e);
}
}
// Output:
// InsufficientFundsException: Tried to withdraw $150.0
// but only $100.0 is available.
Exception (not extend it) for custom exceptions. Override toString() to provide meaningful error messages. Include relevant data fields so the catcher can understand what went wrong.Error vs Exception
Dart distinguishes between Error and Exception. Understanding the difference is crucial for proper error handling:
- Exception -- Represents conditions that a program should anticipate and catch. Examples: network failures, invalid user input, file not found. These are recoverable.
- Error -- Represents programming bugs or conditions that should not occur. Examples: null pointer access, stack overflow, assertion failures. These are generally not meant to be caught.
Error vs Exception
void main() {
// This is an Exception -- recoverable, should be caught
try {
int.parse('hello');
} on FormatException {
print('Handled: bad input format');
}
// This is an Error -- usually a programming mistake
// You CAN catch it, but generally should NOT
try {
List<int> empty = [];
print(empty[0]); // RangeError (subclass of Error)
} on RangeError {
print('Caught a RangeError -- but fix the code instead!');
}
}
Error types in production code unless you have a very good reason. Errors like StackOverflowError, OutOfMemoryError, and TypeError indicate bugs in your code that should be fixed, not hidden behind catch blocks.Rethrowing Exceptions
Sometimes you want to catch an exception to log it or perform partial handling, then pass it along for someone else to deal with. Use the rethrow keyword to rethrow the original exception with its original stack trace.
Using rethrow
void processData(String data) {
try {
var number = int.parse(data);
print('Processed: $number');
} catch (e) {
print('Logging error: $e');
rethrow; // Pass the exception up to the caller
}
}
void main() {
try {
processData('not_a_number');
} catch (e) {
print('Main caught: $e');
}
}
// Output:
// Logging error: FormatException: not_a_number
// Main caught: FormatException: not_a_number
rethrow instead of throw e. The rethrow keyword preserves the original stack trace, while throw e creates a new one, making debugging harder.Assert Statements (Development Mode)
The assert statement is a development tool that checks if a condition is true. If the condition is false, it throws an AssertionError. Assertions are only active in debug mode and are completely removed in production builds.
Using assert
void setTemperature(double celsius) {
// This check only runs in debug mode
assert(celsius >= -273.15, 'Temperature cannot be below absolute zero!');
print('Temperature set to $celsius°C');
}
void main() {
setTemperature(25.0); // Works fine
setTemperature(-300.0); // AssertionError in debug mode!
}
// In debug mode:
// Temperature set to 25.0°C
// AssertionError: Temperature cannot be below absolute zero!
Assert with Constructors
class Rectangle {
final double width;
final double height;
Rectangle(this.width, this.height)
: assert(width > 0, 'Width must be positive'),
assert(height > 0, 'Height must be positive');
double get area => width * height;
}
void main() {
var rect = Rectangle(5, 10);
print('Area: ${rect.area}'); // Area: 50.0
// In debug mode, this would throw AssertionError:
// var invalid = Rectangle(-1, 10);
}
assert to catch programming errors during development. For conditions that should be checked in production (like user input), use regular if statements and throw exceptions instead.Practical Error Handling Patterns
Let us look at some real-world patterns you will encounter frequently in Dart and Flutter development.
Pattern 1: Parsing User Input Safely
Safe Parsing with tryParse
void main() {
String userInput = 'abc';
// BAD: Throws an exception
// int number = int.parse(userInput);
// GOOD: Returns null if parsing fails
int? number = int.tryParse(userInput);
if (number != null) {
print('Parsed: $number');
} else {
print('Invalid number: $userInput');
}
}
// Output:
// Invalid number: abc
Pattern 2: Multiple Operation Error Handling
Handling Errors in Sequential Operations
class ApiException implements Exception {
final int statusCode;
final String message;
ApiException(this.statusCode, this.message);
@override
String toString() => 'ApiException [$statusCode]: $message';
}
class UserService {
Map<String, dynamic> fetchUser(int id) {
if (id < 0) {
throw ArgumentError('User ID must be positive');
}
if (id == 0) {
throw ApiException(404, 'User not found');
}
return {'id': id, 'name': 'User $id'};
}
void deleteUser(int id) {
if (id == 1) {
throw ApiException(403, 'Cannot delete admin user');
}
print('User $id deleted successfully');
}
}
void main() {
var service = UserService();
// Handling different error types
for (var id in [-1, 0, 1, 42]) {
try {
var user = service.fetchUser(id);
print('Found: ${user['name']}');
service.deleteUser(id);
} on ArgumentError catch (e) {
print('Invalid argument: ${e.message}');
} on ApiException catch (e) {
print('API error: $e');
} catch (e) {
print('Unexpected error: $e');
}
print('---');
}
}
Pattern 3: Result Type Pattern (Avoiding Exceptions)
Using a Result Class
class Result<T> {
final T? value;
final String? error;
final bool isSuccess;
Result.success(this.value)
: error = null,
isSuccess = true;
Result.failure(this.error)
: value = null,
isSuccess = false;
}
Result<int> safeDivide(int a, int b) {
if (b == 0) {
return Result.failure('Cannot divide by zero');
}
return Result.success(a ~/ b);
}
void main() {
var result1 = safeDivide(10, 3);
if (result1.isSuccess) {
print('Result: ${result1.value}'); // Result: 3
}
var result2 = safeDivide(10, 0);
if (!result2.isSuccess) {
print('Error: ${result2.error}'); // Error: Cannot divide by zero
}
}
When NOT to Use Exceptions
Exceptions are powerful, but using them incorrectly can make your code harder to read and slower to execute. Here are cases where you should avoid them:
- Expected conditions: If a condition is normal and expected (like a user entering no text), use an
ifcheck instead of catching an exception. - Flow control: Never use exceptions to control the normal flow of your program. They should only signal truly exceptional situations.
- Performance-critical code: Throwing and catching exceptions has a performance cost. In tight loops, use return values or null checks instead.
- When tryParse exists: Use
int.tryParse(),double.tryParse(), and similar methods instead of catchingFormatException.
Good vs Bad Exception Usage
// BAD: Using exceptions for normal flow
bool isValidEmailBad(String email) {
try {
if (!email.contains('@')) throw FormatException();
return true;
} on FormatException {
return false;
}
}
// GOOD: Simple conditional logic
bool isValidEmailGood(String email) {
return email.contains('@') && email.contains('.');
}
// BAD: Catching exceptions for expected null
String getUserNameBad(Map<String, dynamic> data) {
try {
return data['name'] as String;
} catch (e) {
return 'Unknown';
}
}
// GOOD: Null-aware check
String getUserNameGood(Map<String, dynamic> data) {
return (data['name'] as String?) ?? 'Unknown';
}
catch (e) at the top level of your application can hide real bugs. Be as specific as possible with your catch clauses, and only use a general catch as a last resort for unexpected errors.Summary
Here is a quick reference of what we covered:
try-catch-finally-- The core structure for handling exceptionson-- Catches a specific exception typecatch (e, stackTrace)-- Captures the exception object and stack tracethrow-- Throws an exceptionrethrow-- Rethrows the current exception preserving the stack trace- Custom exceptions -- Implement
Exceptionfor meaningful error types ErrorvsException-- Errors are bugs; exceptions are recoverable conditionsassert-- Debug-only condition checks removed in production- Use
tryParseand null checks instead of exceptions when possible - Never use exceptions for normal flow control
Practice Exercise
Open DartPad and build a simple User Registration Validator. Create custom exceptions: InvalidEmailException, WeakPasswordException, and UsernameTakenException. Write a registerUser() function that validates the email (must contain @), password (must be at least 8 characters), and username (cannot be "admin" or "root"). Use try-catch with specific on clauses to handle each exception type differently and print user-friendly error messages. Add assert statements to verify that none of the inputs are empty strings.