What You'll Build
A command-line app you run with dart run: add an expense with a category and amount, list all expenses, and print a per-category summary — with input validation via custom exceptions and a pattern-matched summary report.
Prerequisites
- The Dart SDK installed (
dart --versionto check) - A new project:
dart create expense_tracker - Helpful but not required: read Classes & Objects and Exception Handling first
Step by Step
1 Define the Expense model and a custom exception
// bin/expense.dart
class InvalidExpenseException implements Exception {
final String message;
InvalidExpenseException(this.message);
@override
String toString() => 'InvalidExpenseException: $message';
}
class Expense {
final String category;
final double amount;
Expense(this.category, this.amount) {
if (amount <= 0) {
throw InvalidExpenseException('Amount must be positive, got $amount');
}
if (category.trim().isEmpty) {
throw InvalidExpenseException('Category cannot be empty');
}
}
@override
String toString() => '${category.padRight(12)} \$${amount.toStringAsFixed(2)}';
}
Validating inside the constructor means an Expense object can never exist in an invalid state — callers are forced to handle the exception at creation time rather than discovering bad data later.
2 Build the tracker class
class ExpenseTracker {
final List<Expense> _expenses = [];
void add(String category, double amount) {
_expenses.add(Expense(category, amount));
}
List<Expense> get all => List.unmodifiable(_expenses);
double get total => _expenses.fold(0, (sum, e) => sum + e.amount);
Map<String, double> get totalsByCategory {
final totals = <String, double>{};
for (final e in _expenses) {
totals[e.category] = (totals[e.category] ?? 0) + e.amount;
}
return totals;
}
}
List.unmodifiable exposes the expense list for reading without letting callers mutate the tracker's internal state directly — a simple encapsulation pattern.
3 Add a pattern-matched summary label
A small use of Dart 3 pattern matching to classify total spending into a human-readable label.
String spendingLevel(double total) {
return switch (total) {
< 0 => 'invalid', // unreachable given our validation, but exhaustive
0 => 'no spending yet',
> 0 && < 100 => 'light spending',
>= 100 && < 500 => 'moderate spending',
_ => 'heavy spending',
};
}
4 Wire up main() with sample data and error handling
void main() {
final tracker = ExpenseTracker();
final entries = [
('Groceries', 42.50),
('Transport', 15.00),
('Groceries', 30.25),
('Entertainment', 60.00),
('Transport', -5.00), // intentionally invalid, to show error handling
];
for (final (category, amount) in entries) {
try {
tracker.add(category, amount);
print('Added: $category \$${amount.toStringAsFixed(2)}');
} on InvalidExpenseException catch (e) {
print('Skipped invalid entry — $e');
}
}
print('\n--- All Expenses ---');
for (final e in tracker.all) {
print(e);
}
print('\n--- By Category ---');
tracker.totalsByCategory.forEach((category, total) {
print('${category.padRight(15)} \$${total.toStringAsFixed(2)}');
});
print('\nTotal spent: \$${tracker.total.toStringAsFixed(2)}');
print('Spending level: ${spendingLevel(tracker.total)}');
}
The invalid entry (negative transport amount) is caught and skipped without crashing the whole program — exactly the behavior a real CLI tool needs when processing a batch of user input.
5 Run it
dart run bin/expense.dart
You should see each valid expense logged as added, the invalid one logged as skipped, a full listing, a per-category breakdown, and a final total with a spending level label.
Final Working Code
All four steps above belong in a single file, bin/expense.dart, in the order shown — the exception class and model, then the tracker, then the helper function, then main(). Nothing was abbreviated; it runs as-is with dart run.