Bryn Flow

🎯 Build an Expense Tracker CLI in Dart

A real, runnable command-line app that puts classes, collections, exception handling, and Dart 3 pattern matching to work together — no Flutter required, just the Dart SDK.

Beginner–Intermediate ⏱️ ~35 minutes 🧰 Dart SDK only
← Dart Track

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

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.

What to Try Next

Want more Dart walkthroughs?

Head back to the Dart track for the full topic list, roadmap, and practice quiz.

Back to Dart Track