Dart Programming Fundamentals

Error Handling & Exceptions

40 min Lesson 13 of 13

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.

Note: Dart uses exceptions (not error codes) as its primary mechanism for reporting runtime problems. This is similar to languages like Java, Python, and C#.

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.
Pro Tip: The 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 object e.
  • 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');
}
Note: Stack traces are invaluable for debugging. In production, you would typically log them rather than display them to users. They show the sequence of function calls that led to the error.

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 failures
  • HttpException -- thrown for HTTP-related errors
  • IntegerDivisionByZeroException -- thrown when dividing an integer by zero
  • StateError -- thrown when an object is in an invalid state
  • ArgumentError -- thrown when a function receives an invalid argument
  • RangeError -- 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.
Pro Tip: Always implement 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!');
  }
}
Warning: Do not catch 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
Note: Use 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);
}
Pro Tip: Use 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 if check 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 catching FormatException.

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';
}
Warning: Catching all exceptions with a bare 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 exceptions
  • on -- Catches a specific exception type
  • catch (e, stackTrace) -- Captures the exception object and stack trace
  • throw -- Throws an exception
  • rethrow -- Rethrows the current exception preserving the stack trace
  • Custom exceptions -- Implement Exception for meaningful error types
  • Error vs Exception -- Errors are bugs; exceptions are recoverable conditions
  • assert -- Debug-only condition checks removed in production
  • Use tryParse and 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.

Tutorial Complete!

Congratulations! You have completed all lessons in this tutorial.