Watch the video (20 min)
On this page24 sections
  1. Prerequisites
  2. Connection With Previous Lectures
  3. Learning Objectives
  4. 1. The Model Layer
  5. 2. Money — An Extension Type Over int
  6. 3. Category — An Enhanced Enum
  7. 4. DateLabels — A Small Extension
  8. 5. Wallet — An Immutable Class
  9. 6. Budget — The Same Recipe, Smaller
  10. 7. Transaction — A Sealed Family
  11. 8. effectOn — The Heart of Every Balance
  12. 9. describe — One Line per Transaction
  13. 10. JSON With a type Key
  14. 11. The Compiler as a To-Do List
  15. 12. Wire It Up and Run
  16. Code Examples
  17. Common Beginner Mistakes
  18. Best Practices
  19. Mini Challenge
  20. Practice Questions
  21. Practice Answers
  22. Quick Revision
  23. Key Takeaways
  24. What's Next?

Lecture 133 — Project Models

Category23 — Final Dart Project
Lecture133 of 140
Estimated video length20 min
TargetDart 3.12.2
LevelAdvanced

The plan from Lecture 132 named the nouns of the Student Budget Tracker: money, categories, wallets, budgets and transactions. Today each noun becomes a small, immutable Dart type that knows how to print itself, copy itself and travel as JSON.

Contents

Prerequisites · Connection · Objectives · Lesson · Code Examples · Common Mistakes · Best Practices · Mini Challenge · Practice Questions · Answers · Takeaways

Every error message in this lecture is real. Each one was produced by running the code shown on Dart 3.12.2, so you will recognize it when you meet it yourself.


Prerequisites

Students should already understand, from Lectures 054–055, 068–070, 080–082, 109, 124 and 130:

  • const, factory constructors, == / hashCode, copyWith and final class
  • enhanced enums, extension methods and extension types
  • map and object patterns, sealed classes, fromJson / toJson

You also need the budget_tracker project as it stands at the end of Lecture 132.


Connection With Previous Lectures

At the end of Lecture 132 the project could only describe itself:

output
$ dart run
Student Budget Tracker v0.1.0
Planned commands:
  ...

Ali needs a wallet and Zayan needs to record lunch, but nothing can hold a wallet or a lunch. Before any rule can exist, the data needs a shape: the model layer, the bottom of bin → services → repository → models.


Learning Objectives

By the end of this lecture you will be able to:

  • Store money safely as whole paisa in an extension type with operators
  • Model a fixed list of categories as an enhanced enum with a safe tryParse
  • Build immutable Wallet and Budget classes with const, copyWith and JSON
  • Model income, expense and transfer as one sealed Transaction family
  • Compute a wallet's balance change with a single exhaustive switch
  • Write and read a type discriminator in JSON with map patterns
  • Use the compiler's exhaustiveness errors as a to-do list

1. The Model Layer

Create two folders inside lib/src/ and six files:

output
lib/src/
├── app_info.dart                 (from Lecture 132)
├── extensions/
│   └── date_extensions.dart      new
└── models/
    ├── money.dart                new
    ├── category.dart             new
    ├── wallet.dart               new
    ├── budget.dart               new
    └── transaction.dart          new

Models import only each other and the date extension. They never print or read files.


2. Money — An Extension Type Over int

Lecture 132 showed that 0.1 + 0.2 is not 0.3 with double. So we count paisa in an int: Rs 450.50 is 45050. Create lib/src/models/money.dart:

dart
/// An amount of money stored as whole cents (paisa), so sums never lose
/// precision the way `double` does.
extension type const Money(int cents) {
  /// No money at all.
  static const zero = Money(0);

  /// Reads text such as `250` or `12.50`; returns `null` for anything else.
  static Money? tryParse(String text) {
    final value = double.tryParse(text.trim());
    if (value == null || value.isNaN || value.isInfinite) return null;
    return Money((value * 100).round());
  }

  Money operator +(Money other) => Money(cents + other.cents);
  Money operator -(Money other) => Money(cents - other.cents);
  Money operator -() => Money(-cents);
  bool operator >(Money other) => cents > other.cents;
  bool operator <(Money other) => cents < other.cents;

  /// Whether this amount is more than zero.
  bool get isPositive => cents > 0;

  /// Compares two amounts, for sorting.
  int compareTo(Money other) => cents.compareTo(other.cents);

  /// Formats the amount as `Rs 12.50` or `-Rs 3.00`.
  String format() {
    final sign = cents < 0 ? '-' : '';
    final rupees = cents.abs() ~/ 100;
    final paisa = (cents.abs() % 100).toString().padLeft(2, '0');
    return '${sign}Rs $rupees.$paisa';
  }
}

Why an extension type (Lecture 070) and not a class?

  • No cost. At runtime a Money is an int.
  • No mix-ups. Money + int is a compile error.
  • Erased at runtime. print(Money(5)) prints 5, which is why format() exists.

const before Money(int cents) allows const Money(500000). The operators (Lecture 021) keep results Money. tryParse returns null for bad text; the services will turn that into an error message.


3. Category — An Enhanced Enum

Spending categories are a fixed list, so they are an enum (Lecture 068). Create lib/src/models/category.dart:

dart
/// What an expense was spent on.
enum Category {
  food('Food', isEssential: true),
  transport('Transport', isEssential: true),
  books('Books', isEssential: true),
  rent('Rent', isEssential: true),
  fun('Fun'),
  other('Other');

  const Category(this.label, {this.isEssential = false});

  /// The name shown to the user.
  final String label;

  /// Whether a student really needs this kind of spending.
  final bool isEssential;

  /// Finds a category by its name, ignoring case; `null` when unknown.
  static Category? tryParse(String text) {
    final wanted = text.trim().toLowerCase();
    for (final category in values) {
      if (category.name == wanted) return category;
    }
    return null;
  }

  /// Reads a category saved in JSON; throws [FormatException] if unknown.
  static Category fromJson(String name) =>
      tryParse(name) ?? (throw FormatException('Unknown category: $name'));
}

Lecture 109 used values.byName for enums in JSON, but it throws an ArgumentError. We want every bad-data problem to be a FormatException, so a damaged file can be reported in one place; ?? (throw ...) does that in one line.


4. DateLabels — A Small Extension

Transactions print dates as 2026-10-03 and reports group by month. Create lib/src/extensions/date_extensions.dart:

dart
/// Small date helpers used by the models and reports.
extension DateLabels on DateTime {
  /// The date as `yyyy-mm-dd`.
  String get dateLabel => toIso8601String().substring(0, 10);

  /// The month as `yyyy-mm`.
  String get monthLabel => toIso8601String().substring(0, 7);

  /// Whether this date is in the same month and year as [other].
  bool isSameMonth(DateTime other) =>
      year == other.year && month == other.month;
}

An extension (Lecture 069) adds these to every DateTime.


5. Wallet — An Immutable Class

Create lib/src/models/wallet.dart:

dart
import 'money.dart';

/// A place where a student keeps money, such as cash or a bank card.
final class Wallet {
  const Wallet({
    required this.id,
    required this.owner,
    required this.name,
    this.openingBalance = Money.zero,
  });

  /// Reads a wallet from decoded JSON; throws [FormatException] if invalid.
  factory Wallet.fromJson(Map<String, Object?> json) => switch (json) {
    {
      'id': String id,
      'owner': String owner,
      'name': String name,
      'openingBalance': int cents,
    } =>
      Wallet(id: id, owner: owner, name: name, openingBalance: Money(cents)),
    _ => throw FormatException('Invalid wallet: $json'),
  };

  final String id;
  final String owner;
  final String name;
  final Money openingBalance;

  /// Returns a new wallet with some fields replaced.
  Wallet copyWith({String? owner, String? name, Money? openingBalance}) =>
      Wallet(
        id: id,
        owner: owner ?? this.owner,
        name: name ?? this.name,
        openingBalance: openingBalance ?? this.openingBalance,
      );

  Map<String, Object?> toJson() => {
    'id': id,
    'owner': owner,
    'name': name,
    'openingBalance': openingBalance.cents,
  };

  @override
  bool operator ==(Object other) =>
      other is Wallet &&
      other.id == id &&
      other.owner == owner &&
      other.name == name &&
      other.openingBalance == openingBalance;

  @override
  int get hashCode => Object.hash(id, owner, name, openingBalance);

  @override
  String toString() => 'Wallet($id, $owner, $name)';
}
  • final fields and a const constructor: a wallet never changes (Lecture 124).
  • copyWith builds a new wallet. id is not a parameter: an identity never changes.
  • fromJson's single map pattern checks every key and its type; anything else is a FormatException. JSON stores cents: "openingBalance": 200000 is Rs 2000.00.
  • == and hashCode compare by value, so a wallet read back from a file equals the saved one.
  • final class (Lecture 130): other libraries can use Wallet but not subclass it.

6. Budget — The Same Recipe, Smaller

Create lib/src/models/budget.dart:

dart
import 'category.dart';
import 'money.dart';

/// A monthly spending limit for one category.
final class Budget {
  const Budget({required this.category, required this.monthlyLimit});

  /// Reads a budget from decoded JSON; throws [FormatException] if invalid.
  factory Budget.fromJson(Map<String, Object?> json) => switch (json) {
    {'category': String name, 'monthlyLimit': int cents} => Budget(
      category: Category.fromJson(name),
      monthlyLimit: Money(cents),
    ),
    _ => throw FormatException('Invalid budget: $json'),
  };

  final Category category;
  final Money monthlyLimit;

  /// Returns a new budget with a different limit.
  Budget copyWith({Money? monthlyLimit}) => Budget(
    category: category,
    monthlyLimit: monthlyLimit ?? this.monthlyLimit,
  );

  Map<String, Object?> toJson() => {
    'category': category.name,
    'monthlyLimit': monthlyLimit.cents,
  };

  @override
  String toString() => 'Budget(${category.label}: ${monthlyLimit.format()})';
}

The category is saved by its name ("food") and read back through Category.fromJson.


7. Transaction — A Sealed Family

A transaction is always exactly one of three kinds, each with one extra field: income has a source, an expense a category, a transfer a toWalletId. That is a sealed class (Lecture 082). Create lib/src/models/transaction.dart; we write it in four parts. First the shared fields and the three subclasses (the subclasses go at the bottom of the file):

dart
import '../extensions/date_extensions.dart';
import 'category.dart';
import 'money.dart';

/// One movement of money. Every transaction is exactly one of
/// [Income], [Expense] or [Transfer].
sealed class Transaction {
  const Transaction({
    required this.id,
    required this.walletId,
    required this.amount,
    required this.date,
    this.note = '',
  });

  // fromJson goes here (section 10)

  final String id;
  final String walletId;
  final Money amount;
  final DateTime date;
  final String note;

  // involves, effectOn, describe and toJson go here (sections 8-10)
}

/// Money coming into a wallet, such as pocket money or a salary.
final class Income extends Transaction {
  const Income({
    required super.id,
    required super.walletId,
    required super.amount,
    required super.date,
    super.note,
    required this.source,
  });

  final String source;
}

/// Money spent from a wallet on one [Category].
final class Expense extends Transaction {
  const Expense({
    required super.id,
    required super.walletId,
    required super.amount,
    required super.date,
    super.note,
    required this.category,
  });

  final Category category;

  /// Returns a corrected copy, for example with a different category.
  Expense copyWith({Money? amount, Category? category, String? note}) =>
      Expense(
        id: id,
        walletId: walletId,
        amount: amount ?? this.amount,
        date: date,
        note: note ?? this.note,
        category: category ?? this.category,
      );
}

/// Money moved from one wallet ([walletId]) to another ([toWalletId]).
final class Transfer extends Transaction {
  const Transfer({
    required super.id,
    required super.walletId,
    required super.amount,
    required super.date,
    super.note,
    required this.toWalletId,
  });

  final String toWalletId;
}

super.id (Lecture 061) passes each shared field up. For a transfer, walletId is the sender. Only Expense gets copyWith: fixing a wrong category is a real need.


8. effectOn — The Heart of Every Balance

A wallet's balance is its opening balance plus the effect of every transaction. Add these two methods inside Transaction:

dart
  /// Whether this transaction touches the wallet with id [wallet].
  bool involves(String wallet) => switch (this) {
    Transfer(:final toWalletId) => walletId == wallet || toWalletId == wallet,
    _ => walletId == wallet,
  };

  /// How this transaction changes the balance of the wallet [wallet].
  Money effectOn(String wallet) {
    if (!involves(wallet)) return Money.zero;
    return switch (this) {
      Income() => amount,
      Expense() => -amount,
      Transfer(:final toWalletId) => toWalletId == wallet ? amount : -amount,
    };
  }

Income adds, an expense subtracts, a transfer subtracts from the sender and adds to the receiver. Transfer(:final toWalletId) checks the kind and reads its field. Because Transaction is sealed, the effectOn switch needs no _.


9. describe — One Line per Transaction

Add the method that history and reports will print:

dart
  /// A one-line description for lists and reports.
  String describe() {
    final what = switch (this) {
      Income(:final source) => 'Income from $source',
      Expense(:final category) => '${category.label} expense',
      Transfer(:final toWalletId) => 'Transfer $walletId -> $toWalletId',
    };
    final extra = note.isEmpty ? '' : ' ($note)';
    return '${date.dateLabel}  $what$extra  ${amount.format()}';
  }

10. JSON With a type Key

A JSON object has no class name, so toJson writes a discriminator, "type". Add it inside Transaction:

dart
  Map<String, Object?> toJson() => {
    'type': switch (this) {
      Income() => 'income',
      Expense() => 'expense',
      Transfer() => 'transfer',
    },
    'id': id,
    'walletId': walletId,
    'amount': amount.cents,
    'date': date.toIso8601String(),
    if (note.isNotEmpty) 'note': note,
    ...switch (this) {
      Income(:final source) => {'source': source},
      Expense(:final category) => {'category': category.name},
      Transfer(:final toWalletId) => {'toWalletId': toWalletId},
    },
  };

Collection if skips an empty note; the spread switch (Lecture 046) adds each kind's own field. Reading back is the mirror image. Add this factory right after the constructor:

dart
  /// Reads any kind of transaction from decoded JSON, using its `type` key.
  factory Transaction.fromJson(Map<String, Object?> json) {
    if (json case {
      'id': String id,
      'walletId': String walletId,
      'amount': int cents,
      'date': String date,
    }) {
      final amount = Money(cents);
      final when = DateTime.parse(date);
      final note = json['note'] as String? ?? '';
      return switch (json) {
        {'type': 'income', 'source': String source} => Income(
          id: id,
          walletId: walletId,
          amount: amount,
          date: when,
          note: note,
          source: source,
        ),
        {'type': 'expense', 'category': String category} => Expense(
          id: id,
          walletId: walletId,
          amount: amount,
          date: when,
          note: note,
          category: Category.fromJson(category),
        ),
        {'type': 'transfer', 'toWalletId': String toWalletId} => Transfer(
          id: id,
          walletId: walletId,
          amount: amount,
          date: when,
          note: note,
          toWalletId: toWalletId,
        ),
        _ => throw FormatException('Unknown transaction type: $json'),
      };
    }
    throw FormatException('Invalid transaction: $json');
  }

An if-case map pattern checks the shared fields, then a switch on type checks the kind's own field. A factory may return a subclass, which is exactly what we need.


11. The Compiler as a To-Do List

Suppose someone later adds final class Refund extends Transaction to transaction.dart and runs the app. Dart refuses to build:

output
lib/src/models/transaction.dart:73:20: Error: The type 'Transaction' is not exhaustively matched by the switch cases since it doesn't match 'Refund()'.
 - 'Transaction' is from 'package:budget_tracker/src/models/transaction.dart' ('lib/src/models/transaction.dart').
Try adding a wildcard pattern or cases that match 'Refund()'.
    return switch (this) {
                   ^

The same error appears for describe and both toJson switches: four places to decide what a refund means. involves is not listed: its _ silently accepts Refund. (Remove Refund again; it is not part of the project.)


12. Wire It Up and Run

Export the new files from lib/budget_tracker.dart:

dart
/// Student Budget Tracker: wallets, transactions, budgets and reports.
library;

export 'src/app_info.dart';
export 'src/extensions/date_extensions.dart';
export 'src/models/budget.dart';
export 'src/models/category.dart';
export 'src/models/money.dart';
export 'src/models/transaction.dart';
export 'src/models/wallet.dart';

Then replace bin/budget_tracker.dart with a temporary models demo (Lecture 135 makes bin/ the real command-line app):

dart
import 'dart:convert';

import 'package:budget_tracker/budget_tracker.dart';

void main(List<String> arguments) {
  print('$appName v$appVersion - models demo\n');

  const ali = Wallet(id: 'ali', owner: 'Ali', name: 'Pocket money');
  final aliCard = ali.copyWith(
    name: 'Bank card',
    openingBalance: Money(500000),
  );
  print(ali);
  print('${aliCard.name}: ${aliCard.openingBalance.format()}');

  final transactions = <Transaction>[
    Income(
      id: 't0001',
      walletId: 'ali',
      amount: Money(300000),
      date: DateTime(2026, 10, 1),
      source: 'Allowance',
    ),
    Expense(
      id: 't0002',
      walletId: 'ali',
      amount: Money(45050),
      date: DateTime(2026, 10, 3),
      note: 'lunch',
      category: Category.food,
    ),
    Transfer(
      id: 't0003',
      walletId: 'ali',
      amount: Money(100000),
      date: DateTime(2026, 10, 5),
      toWalletId: 'sara',
    ),
  ];

  print('\nTransactions:');
  for (final t in transactions) {
    print('  ${t.describe()}');
  }

  print('\nEffect on each wallet:');
  for (final t in transactions) {
    print(
      '  ${t.id}: ali ${t.effectOn('ali').format()}, '
      'sara ${t.effectOn('sara').format()}',
    );
  }

  final json = jsonEncode(transactions);
  print('\nJSON: $json');
  final decoded = (jsonDecode(json) as List)
      .cast<Map<String, Object?>>()
      .map(Transaction.fromJson)
      .toList();
  print(
    'Read back ${decoded.length} transactions; '
    'second is ${decoded[1].runtimeType}',
  );

  print('\nMoney.tryParse("12.5") = ${Money.tryParse('12.5')?.format()}');
  print('Money.tryParse("abc") = ${Money.tryParse('abc')}');
  print('Category.tryParse("Food") = ${Category.tryParse('Food')}');
  const budget = Budget(category: Category.food, monthlyLimit: Money(1000000));
  print('$budget -> ${budget.copyWith(monthlyLimit: Money(800000))}');
}

jsonEncode calls each transaction's toJson for us. The demo uses fixed October 2026 dates, so your output matches this one on any day:

output
$ dart run
Student Budget Tracker v0.1.0 - models demo

Wallet(ali, Ali, Pocket money)
Bank card: Rs 5000.00

Transactions:
  2026-10-01  Income from Allowance  Rs 3000.00
  2026-10-03  Food expense (lunch)  Rs 450.50
  2026-10-05  Transfer ali -> sara  Rs 1000.00

Effect on each wallet:
  t0001: ali Rs 3000.00, sara Rs 0.00
  t0002: ali -Rs 450.50, sara Rs 0.00
  t0003: ali -Rs 1000.00, sara Rs 1000.00

JSON: [{"type":"income","id":"t0001","walletId":"ali","amount":300000,"date":"2026-10-01T00:00:00.000","source":"Allowance"},{"type":"expense","id":"t0002","walletId":"ali","amount":45050,"date":"2026-10-03T00:00:00.000","note":"lunch","category":"food"},{"type":"transfer","id":"t0003","walletId":"ali","amount":100000,"date":"2026-10-05T00:00:00.000","toWalletId":"sara"}]
Read back 3 transactions; second is Expense

Money.tryParse("12.5") = Rs 12.50
Money.tryParse("abc") = null
Category.tryParse("Food") = Category.food
Budget(Food: Rs 10000.00) -> Budget(Food: Rs 8000.00)

Finish with the quality checks. The smoke test from Lecture 132 is unchanged:

output
$ dart analyze
Analyzing budget_tracker...
No issues found!
$ dart test -r expanded
00:00 +0: loading test/budget_tracker_test.dart
00:00 +0: test/budget_tracker_test.dart: the app has a version
00:00 +1: All tests passed!

Code Examples

Save each example in a new example/ folder (say example/wallets.dart) and run it from the project root with dart run example/wallets.dart. These are scratch files.

Example 1 — A Wallet Compared by Value

dart
import 'package:budget_tracker/budget_tracker.dart';

void main() {
  const ali = Wallet(id: 'ali', owner: 'Ali', name: 'Pocket money');
  print(ali);
  print(ali.openingBalance.format());

  final again = Wallet(id: 'ali', owner: 'Ali', name: 'Pocket money');
  print(ali == again);
  print(identical(ali, again));
}
output
Wallet(ali, Ali, Pocket money)
Rs 0.00
true
false

Example 2 — copyWith Leaves the Original Alone

dart
import 'package:budget_tracker/budget_tracker.dart';

void main() {
  const ali = Wallet(id: 'ali', owner: 'Ali', name: 'Pocket money');
  final renamed = ali.copyWith(name: 'School cash');
  print(ali.name);
  print(renamed.name);
  print(renamed.id);
  print(ali == renamed);
}
output
Pocket money
School cash
ali
false

Example 3 — Money Arithmetic

dart
import 'package:budget_tracker/budget_tracker.dart';

void main() {
  const lunch = Money(45050);
  const bus = Money(8000);
  final total = lunch + bus;
  print(total.format());
  print((bus - lunch).format());
  print(total > lunch);
  print(total.cents);
  print(total);
}
output
Rs 530.50
-Rs 370.50
true
53050
53050

Example 4 — Money.tryParse

dart
import 'package:budget_tracker/budget_tracker.dart';

void main() {
  for (final text in ['12.5', 'abc', ' 250 ', '0.1', 'NaN']) {
    final money = Money.tryParse(text);
    print('"$text" -> ${money?.format() ?? 'not an amount'}');
  }
}
output
"12.5" -> Rs 12.50
"abc" -> not an amount
" 250 " -> Rs 250.00
"0.1" -> Rs 0.10
"NaN" -> not an amount

double.tryParse('NaN') succeeds and returns NaN, which is why tryParse checks isNaN.

Example 5 — Categories

dart
import 'package:budget_tracker/budget_tracker.dart';

void main() {
  final essentials = Category.values.where((c) => c.isEssential);
  print('Essential: ${essentials.map((c) => c.label).join(', ')}');

  for (final text in ['Food', ' RENT ', 'Fun', 'games']) {
    print('$text -> ${Category.tryParse(text)?.label ?? 'unknown'}');
  }
}
output
Essential: Food, Transport, Books, Rent
Food -> Food
 RENT  -> Rent
Fun -> Fun
games -> unknown

Example 6 — One Transfer, Three Points of View

dart
import 'package:budget_tracker/budget_tracker.dart';

void main() {
  final transfer = Transfer(
    id: 't0010',
    walletId: 'ali',
    amount: Money(50000),
    date: DateTime(2026, 10, 7),
    note: 'bus pass share',
    toWalletId: 'sara',
  );
  print(transfer.describe());
  for (final wallet in ['ali', 'sara', 'zayan']) {
    print(
      '$wallet: ${transfer.involves(wallet)} '
      '${transfer.effectOn(wallet).format()}',
    );
  }
}
output
2026-10-07  Transfer ali -> sara (bus pass share)  Rs 500.00
ali: true -Rs 500.00
sara: true Rs 500.00
zayan: false Rs 0.00

Example 7 — A JSON Round Trip

dart
import 'dart:convert';

import 'package:budget_tracker/budget_tracker.dart';

void main() {
  final income = Income(
    id: 't0001',
    walletId: 'sara',
    amount: Money(150000),
    date: DateTime(2026, 10, 1),
    source: 'Scholarship',
  );
  final json = jsonEncode(income);
  print(json);

  final back = Transaction.fromJson(jsonDecode(json) as Map<String, Object?>);
  print(back.runtimeType);
  print(back.describe());
  if (back case Income(:final source)) print('source: $source');
}
output
{"type":"income","id":"t0001","walletId":"sara","amount":150000,"date":"2026-10-01T00:00:00.000","source":"Scholarship"}
Income
2026-10-01  Income from Scholarship  Rs 1500.00
source: Scholarship

Example 8 — Bad JSON Is Always a FormatException

dart
import 'package:budget_tracker/budget_tracker.dart';

void main() {
  final bad = <Map<String, Object?>>[
    {
      'type': 'gift',
      'id': 't0009',
      'walletId': 'ali',
      'amount': 1000,
      'date': '2026-10-09T00:00:00.000',
    },
    {'id': 't0011', 'walletId': 'ali', 'amount': '10.00'},
  ];
  for (final json in bad) {
    try {
      Transaction.fromJson(json);
    } on FormatException catch (e) {
      print(e.message);
    }
  }
}
output
Unknown transaction type: {type: gift, id: t0009, walletId: ali, amount: 1000, date: 2026-10-09T00:00:00.000}
Invalid transaction: {id: t0011, walletId: ali, amount: 10.00}

Common Beginner Mistakes

1. Mixing Money and int

dart
const lunch = Money(45050);
// final total = lunch + 8000; // ✗
// int cents = Money(500);     // ✗
output
Error: A value of type 'int' can't be assigned to a variable of type 'Money'.
Error: A value of type 'Money' can't be assigned to a variable of type 'int'.

Write lunch + Money(8000), or read .cents when you really need the int.

2. Changing a model in place

dart
const ali = Wallet(id: 'ali', owner: 'Ali', name: 'Pocket money');
// ali.name = 'School cash'; // ✗
output
Error: The setter 'name' isn't defined for the type 'Wallet'.

Models are immutable. Use ali.copyWith(name: 'School cash').

3. Using values.byName for user data

dart
// print(Category.values.byName('games')); // ✗ crashes at runtime
output
Unhandled exception:
Invalid argument (name): No enum value with that name: "games"

Use Category.tryParse (returns null) or Category.fromJson (throws FormatException).


Best Practices

  • Keep models free of printing, files and business rules; they only describe data.
  • Make every field final, give classes const constructors, and change data with copyWith.
  • Wrap meaningful numbers (money, ids) in extension types so the compiler catches mix-ups.
  • Return null from tryParse for user input; throw FormatException for bad saved data.
  • Write a type key in JSON when one field can hold several kinds of object.
  • Avoid _ in switches over a sealed type, so a new subtype is reported everywhere.

Mini Challenge

Using only the models, add up Ali's food spending in October 2026. Use an if-case object pattern with a when guard and isSameMonth. The September expense must not count.

output
Food in 2026-10: Rs 570.50

Try it yourself before opening the solution.

Show solution
dart
import 'package:budget_tracker/budget_tracker.dart';

void main() {
  final month = DateTime(2026, 10);
  final transactions = <Transaction>[
    Expense(
      id: 't1',
      walletId: 'ali',
      amount: Money(45050),
      date: DateTime(2026, 10, 3),
      category: Category.food,
    ),
    Expense(
      id: 't3',
      walletId: 'ali',
      amount: Money(30000),
      date: DateTime(2026, 9, 28),
      category: Category.food,
    ),
    Expense(
      id: 't4',
      walletId: 'ali',
      amount: Money(12000),
      date: DateTime(2026, 10, 6),
      category: Category.food,
    ),
  ];

  var food = Money.zero;
  for (final t in transactions) {
    if (t case Expense(
      category: Category.food,
      :final amount,
      :final date,
    ) when date.isSameMonth(month)) {
      food = food + amount;
    }
  }
  print('Food in ${month.monthLabel}: ${food.format()}');
}
output
Food in 2026-10: Rs 570.50

The pattern accepts only food expenses, the guard only October: Rs 450.50 + Rs 120.00.


Practice Questions

Write each answer as a program in example/.

Beginner

  1. Print Money(123456), Money(5) and -Money(250) with format().
  2. Sara's lunch costs Rs 320.75 and a book Rs 1200. Add them as Money and print the total.
  3. Print the label of every category that is not essential.

Application

  1. Predict, then check, what Money.tryParse gives for '99.999', '-20', '1e3' and ''. Which result should a spend command refuse?
  2. Sara recorded a Rs 650 novel (note novel, 8 October 2026) as Category.fun. Fix it with copyWith so it is Category.books, and print describe() for both.
  3. Sara starts with Rs 2000. She pays Rs 400 rent and transfers Rs 250 to Zayan. Compute her balance with fold and effectOn.
  4. Create a Rs 1500 Budget for books, print its toJson(), then read it back with Budget.fromJson and print the result.

Challenge

  1. Without editing wallet.dart, write Wallet checkedWallet(Map<String, Object?> json) that throws a FormatException for a negative openingBalance and otherwise calls Wallet.fromJson. Test it with a valid wallet and with Bilal's wallet at -100 cents.

Practice Answers

Question 1 — Solution

dart
import 'package:budget_tracker/budget_tracker.dart';

void main() {
  print(Money(123456).format());
  print(Money(5).format());
  print((-Money(250)).format());
}
output
Rs 1234.56
Rs 0.05
-Rs 2.50

Question 2 — Solution

dart
import 'package:budget_tracker/budget_tracker.dart';

void main() {
  const lunch = Money(32075);
  const book = Money(120000);
  print((lunch + book).format());
}
output
Rs 1520.75

Question 3 — Solution

dart
import 'package:budget_tracker/budget_tracker.dart';

void main() {
  for (final category in Category.values) {
    if (!category.isEssential) print(category.label);
  }
}
output
Fun
Other

Question 4 — Solution

dart
import 'package:budget_tracker/budget_tracker.dart';

void main() {
  for (final text in ['99.999', '-20', '1e3', '']) {
    print('"$text" -> ${Money.tryParse(text)?.format()}');
  }
}
output
"99.999" -> Rs 100.00
"-20" -> -Rs 20.00
"1e3" -> Rs 1000.00
"" -> null

9999.9 paisa rounds to 10000. -20 parses, but refusing non-positive amounts is a business rule, so the services (not tryParse) must reject it.

Question 5 — Solution

dart
import 'package:budget_tracker/budget_tracker.dart';

void main() {
  final wrong = Expense(
    id: 't0020',
    walletId: 'sara',
    amount: Money(65000),
    date: DateTime(2026, 10, 8),
    note: 'novel',
    category: Category.fun,
  );
  final fixed = wrong.copyWith(category: Category.books);
  print(wrong.describe());
  print(fixed.describe());
}
output
2026-10-08  Fun expense (novel)  Rs 650.00
2026-10-08  Books expense (novel)  Rs 650.00

Question 6 — Solution

dart
import 'package:budget_tracker/budget_tracker.dart';

void main() {
  const sara = Wallet(
    id: 'sara',
    owner: 'Sara',
    name: 'Bank card',
    openingBalance: Money(200000),
  );
  final transactions = <Transaction>[
    Expense(
      id: 't1',
      walletId: 'sara',
      amount: Money(40000),
      date: DateTime(2026, 10, 2),
      category: Category.rent,
    ),
    Transfer(
      id: 't2',
      walletId: 'sara',
      amount: Money(25000),
      date: DateTime(2026, 10, 3),
      toWalletId: 'zayan',
    ),
  ];
  final balance = transactions.fold(
    sara.openingBalance,
    (sum, t) => sum + t.effectOn(sara.id),
  );
  print('${sara.owner}: ${balance.format()}');
}
output
Sara: Rs 1350.00

Question 7 — Solution

dart
import 'package:budget_tracker/budget_tracker.dart';

void main() {
  const books = Budget(category: Category.books, monthlyLimit: Money(150000));
  final json = books.toJson();
  print(json);
  print(Budget.fromJson(json));
}
output
{category: books, monthlyLimit: 150000}
Budget(Books: Rs 1500.00)

The limit travels as cents and the category as its name; fromJson turns both back.

Question 8 — Solution

dart
import 'package:budget_tracker/budget_tracker.dart';

Wallet checkedWallet(Map<String, Object?> json) => switch (json) {
  {'openingBalance': int cents} when cents < 0 => throw FormatException(
    'Negative opening balance: $json',
  ),
  _ => Wallet.fromJson(json),
};

void main() {
  final inputs = <Map<String, Object?>>[
    {'id': 'ali', 'owner': 'Ali', 'name': 'Cash', 'openingBalance': 5000},
    {'id': 'bilal', 'owner': 'Bilal', 'name': 'Cash', 'openingBalance': -100},
  ];
  for (final json in inputs) {
    try {
      print(checkedWallet(json));
    } on FormatException catch (e) {
      print(e.message);
    }
  }
}
output
Wallet(ali, Ali, Cash)
Negative opening balance: {id: bilal, owner: Bilal, name: Cash, openingBalance: -100}

The guarded map pattern catches only the bad case; the factory still checks every field.


Quick Revision

  1. Why does Money store paisa in an int instead of rupees in a double?
  2. What does print(Money(500)) show, and why?
  3. Why does Category.fromJson throw FormatException instead of using values.byName?
  4. What does effectOn return for a transfer, seen from the sender? From the receiver?
  5. Why did adding Refund produce four errors and not five?

Key Takeaways

  • Money is an extension type over paisa: exact sums, no runtime cost, no mixing with int.
  • Category is an enhanced enum; Wallet and Budget are immutable with copyWith and JSON.
  • Transaction is sealed: income, expense and transfer, each with its own field.
  • effectOn decides every balance change in one exhaustive switch.
  • JSON carries a type key; fromJson checks shared fields, then switches on the type.
  • Every kind of bad saved data becomes a FormatException.
  • A new subtype makes the compiler list every switch to fix, unless a _ hides it.

What's Next?

Lecture 134 — Project Services. Our models accept nonsense: an expense bigger than the wallet, a transfer to a missing wallet, an income of Rs 0. Next we write LedgerService and BudgetService, which hold the business rules, refuse bad requests with our own exceptions, and produce the first monthly report.


Video lecture

Subscribe
Placeholder video — the real recording isn't linked yet.