Introduction
This is my fourth article as a Java engineer learning TypeScript from scratch.
In my previous article, I built a Todo List CLI and learned about interface, array methods, immutable patterns, and Jest's beforeEach. This time, I built a Household Budget CLI and focused on:
- Organizing logic with a class (closer to how I'd write Java)
- Designing a shared interface with generics
<T> - Reducing duplication with
interfaceinheritance (extends) - Using
reduce/filter/some/findin real logic - Writing date and category validation from scratch
- Testing class methods with Jest (13 test cases)
Same as always — I write honestly about where I got stuck, what I thought through, and what I asked AI for.
My Learning Style (AI Transparency)
I use Claude Pro (design discussions and Q&A) and Cursor Pro (coding support) as learning companions.
My rules:
- I write all the code myself — I never ask AI to write code for me
- AI helps with hints, spec clarification, and bug spotting
- I make sure I understand why something works before moving on
In this article, I clearly separate "what I implemented myself" from "what I asked AI for."
What I Built
A CLI tool to record, edit, delete, and summarize household income and expenses.
================================================
1. Add income
2. Add expense
3. Show all records
4. Show balance summary
5. Edit income
6. Edit expense
7. Delete a record
8. Exit
> 1
Enter amount: 250000
Enter date: 2026/05/23
Choose a category from the following:
給与, 賞与, 副業, その他
Enter category: 給与
Enter description: May salary
Income added.
================================================
Household Budget
================================================
Income:
1: 250000 2026/05/23 給与 May salary
Expenses:
================================================
⚠️ Data is lost when you exit — no persistence. This is purely a learning project.
📦 Repository: https://github.com/uya0526-design/household_account
Project Structure
household_account/
├── src/
│ ├── index.ts # Entry point / CLI menu
│ ├── account.ts # Business logic (AccountBook class)
│ ├── types.ts # Type definitions
│ └── __tests__/
│ └── account.test.ts # Unit tests (13 cases)
├── jest.config.js
├── tsconfig.json
└── package.json
Same three-layer structure as before: types.ts (types) / account.ts (logic) / index.ts (UI).
The key change from the Todo project: logic moved from functions to a class.
Tech Stack
- TypeScript
- Node.js (
readlinemodule) - Jest + ts-jest (unit testing)
What I Implemented Myself
types.ts — Type Definitions
The centerpiece of this project's type design was a shared interface using generics.
My first draft defined Income and Expense as completely separate interfaces:
// First version — lots of duplication
export interface Income {
id: number;
amount: number;
date: string;
category: IncomeCategory;
description: string;
}
export interface Expense {
id: number;
amount: number;
date: string;
category: ExpenseCategory;
description: string;
}
Four fields — id, amount, date, description — are identical. AI suggested that generics could consolidate these, and I refactored it myself:
// Refactored with generics
interface EntityDetail<T> {
id: number;
amount: number;
date: string; // format: YYYY/MM/DD
category: T;
description: string;
}
export interface Income extends EntityDetail<IncomeCategory> {}
export interface Expense extends EntityDetail<ExpenseCategory> {}
By passing IncomeCategory or ExpenseCategory as the type parameter T, only the category field differs between the two. This is the same idea as Java's List<T> — a common structure with a pluggable type.
I also defined the category types and constant lists myself:
export type IncomeCategory = "給与" | "賞与" | "副業" | "その他";
export type ExpenseCategory = "食費" | "交通費" | "光熱費" | "娯楽" | "その他";
export const IncomeCategoryList: IncomeCategory[] = ["給与", "賞与", "副業", "その他"];
export const ExpenseCategoryList: ExpenseCategory[] = ["食費", "交通費", "光熱費", "娯楽", "その他"];
export type Account = {
nextId: number;
incomes: Income[];
expenses: Expense[];
};
The constant lists serve double duty: they're used for both validation and displaying choices to the user.
account.ts — The AccountBook Class
My Todo project used pure functions (each returning a new array). This time I switched to a class-based approach, storing state inside the class itself.
export class AccountBook {
private account: Account;
constructor() {
this.account = { nextId: 1, incomes: [], expenses: [] };
}
addIncome(amount: number, date: string, category: IncomeCategory, description: string): void {
const newIncome: Income = { id: this.account.nextId, amount, date, category, description };
this.account.incomes.push(newIncome);
this.account.nextId++;
}
// ...
}
ID generation — learning from last time
In the Todo project, I used todoItems.length + 1 for IDs, which caused duplicates after deletion. This time I introduced nextId as a dedicated field. It only increments — never resets — so IDs stay unique no matter how many deletions happen.
delete — filter to keep everything else
delete(id: number): void {
this.account.incomes = this.account.incomes.filter((income) => income.id !== id);
this.account.expenses = this.account.expenses.filter((expense) => expense.id !== id);
}
Both income and expenses are checked — the user just provides an ID without specifying which type. The logic is "keep everything that isn't this ID."
editIncome / editExpense — delete then re-add
editIncome(id: number, amount: number, date: string, category: IncomeCategory, description: string): void {
this.account.incomes = this.account.incomes.filter((i) => i.id !== id);
const editedIncome: Income = { id, amount, date, category, description };
this.account.incomes.push(editedIncome);
}
Remove the old record, insert a new one with the same ID. Simple and explicit.
displayTotal — reduce for aggregation
displayTotal(): void {
const totalIncome = this.account.incomes.reduce((sum, income) => sum + income.amount, 0);
const totalExpense = this.account.expenses.reduce((sum, expense) => sum + expense.amount, 0);
const balance = totalIncome - totalExpense;
console.log(`Total income: ${totalIncome}`);
console.log(`Total expenses: ${totalExpense}`);
console.log(`Balance: ${balance}`);
}
reduce — "collapse an array into a single value" — is a natural fit for summing amounts. Same mental model as Java's Stream.reduce().
displayIncomeAndExpense — non-destructive sort with spread
displayIncomeAndExpense(): void {
const sortedIncomes = [...this.account.incomes].sort((a, b) => a.id - b.id);
const sortedExpenses = [...this.account.expenses].sort((a, b) => a.id - b.id);
// ...
}
Array.prototype.sort() mutates the original array. Spreading first ([...array]) creates a copy, keeping the stored data intact. AI pointed this out — more on that in the "where I got stuck" section.
isIncomeId / isExpenseId — existence check with some
isIncomeId(id: number): boolean {
return this.account.incomes.some((income) => income.id === id);
}
some returns true if at least one element matches — Java's Stream.anyMatch() equivalent. Used to validate that an ID actually exists before editing or deleting.
getIncome / getExpense — added for testing
getIncome(id: number): Income | undefined {
return this.account.incomes.find((income) => income.id === id);
}
I added these methods myself to make test assertions easier. find returns the first matching element, or undefined if none is found.
index.ts — CLI Menu
Date validation — catching impossible dates
// Format check (YYYY/MM/DD)
const dateRegex = /^\d{4}\/\d{2}\/\d{2}$/;
if (!dateRegex.test(date)) { /* ... */ }
// Existence check — catch things like "2026/02/30"
const dateObj = new Date(date.replace(/\//g, '-'));
if (isNaN(dateObj.getTime())) {
console.log("That date does not exist.");
}
A regex can verify the format, but it can't tell you whether February 30th is real. new Date('2026-02-30') produces an Invalid Date, and isNaN(dateObj.getTime()) detects that. I added this check myself after noticing the gap.
Displaying category options before input
console.log(`Choose a category from the following:`);
console.log(IncomeCategoryList.join(", "));
My first version just asked for a category without showing options. After running it myself and thinking "wait, what are the valid values?", I added the display. Small UX improvement, self-initiated.
Bug fix — || should be && in the delete validation
// ❌ Passes if the ID exists in either list (always true)
if (!accountBook.isIncomeId(id) || !accountBook.isExpenseId(id)) {
// ✅ Only errors when the ID exists in neither list
if (!accountBook.isIncomeId(id) && !accountBook.isExpenseId(id)) {
AI caught this one. "Not in income OR not in expense" is always true for any valid ID — the correct logic is AND.
account.test.ts — Unit Tests with Jest
Class-based testing requires a fresh instance before each test:
import { AccountBook } from '../account';
describe('addIncome', () => {
let accountBook: AccountBook;
beforeEach(() => {
accountBook = new AccountBook();
});
test('can add income', () => {
accountBook.addIncome(1000, '2026/05/23', '給与', 'test');
expect(accountBook.getIncome(1)?.amount).toBe(1000);
});
test('ID increments after adding income', () => {
accountBook.addIncome(1000, '2026/05/23', '給与', 'test');
accountBook.addIncome(2000, '2026/05/24', '賞与', 'test2');
expect(accountBook.getIncome(2)?.id).toBe(2);
});
});
I used describe.skip to exclude display methods (console.log-only) from the test suite.
Test results:
PASS src/__tests__/account.test.ts
addIncome
✓ can add income
✓ ID increments after adding income
addExpense
✓ can add expense
✓ ID increments after adding expense
delete
✓ can delete a record
editIncome
✓ can edit income
editExpense
✓ can edit expense
isIncomeId
✓ returns true for a matching income ID
✓ returns false for a non-matching income ID
isExpenseId
✓ returns true for a matching expense ID
✓ returns false for a non-matching expense ID
getIncome
✓ can retrieve income by ID
getExpense
✓ can retrieve expense by ID
Tests: 13 passed, 13 total
What I Asked AI For
| Topic | What AI helped with |
|---|---|
| Spec decisions | Date format, ID strategy, display format, validation approach — confirmed one by one |
| Design suggestion | Proposed EntityDetail generics (<T>) as a way to consolidate the two interfaces |
| Bug catch | Variable shadowing in editIncome — callback arg income was hiding the outer variable |
| Bug catch | Missing main() calls in certain case branches |
| Bug catch | Logical operator fix in delete validation — changed OR to AND |
| Clarification | Explained that Array.sort() mutates the original — suggested the spread-copy pattern |
Where I Got Stuck
1. sort() without spread mutated the stored array
Situation: The display order of records kept changing unexpectedly between views.
Root cause: Array.prototype.sort() is destructive — it modifies the array in place. I was sorting this.account.incomes directly.
// ❌ Mutates the stored array
this.account.incomes.sort((a, b) => a.id - b.id);
// ✅ Sort a copy, leave the original alone
const sortedIncomes = [...this.account.incomes].sort((a, b) => a.id - b.id);
I had applied the spread pattern in the Todo project for push, but hadn't thought to apply it to sort. AI's reminder made it click for me.
2. Variable shadowing caused undefined IDs in editIncome
Situation: After editing an income record, its ID became undefined.
Root cause: The callback argument and an outer variable shared the same name — the callback's income parameter was shadowing the outer scope.
// ❌ The filter callback's `income` hides the outer `income` variable
const editedIncome: Income = { id: income.id, ... }; // which `income`?
this.account.incomes = this.account.incomes.filter((income) => income.id !== id);
// ✅ Rename the callback argument to avoid the collision
this.account.incomes = this.account.incomes.filter((i) => i.id !== id);
const editedIncome: Income = { id, amount, date, category, description };
This can happen in Java too, but it's especially easy to hit in JavaScript where callbacks are everywhere. Good one to internalize.
3. Regex alone can't catch impossible dates
Situation: "2026/02/30" passed format validation and entered the system.
Root cause: A regex can verify structure, not validity.
// Catches wrong format — but not wrong dates
const dateRegex = /^\d{4}\/\d{2}\/\d{2}$/;
// Catches impossible dates too
const dateObj = new Date(date.replace(/\//g, '-'));
if (isNaN(dateObj.getTime())) {
console.log("That date does not exist.");
}
new Date('2026-02-30') becomes Invalid Date, and isNaN(getTime()) catches it. I noticed this gap on my own and added the check.
4. Forgetting rl.close()
Situation: Selecting "Exit" printed the message but the process didn't end.
Root cause: rl.close() was missing. Without it, the readline interface stays open and the process hangs.
case "8":
rl.close(); // required — otherwise the process never exits
break;
I hit this same bug in the Todo project. Writing it down again so it sticks.
What I Learned
Functions vs. Classes
My Todo project used pure functions (return new arrays, no side effects). This project switched to a class.
| Aspect | Function-based | Class-based |
|---|---|---|
| State management | Caller holds the variable | Class holds it internally |
| Testability | Clear input → output, easy to assert | Need a fresh instance per test |
| Familiarity from Java | More functional-style | Closer to Java OOP |
Neither is strictly better — it depends on the design goals.
TypeScript
| Topic | Key Takeaway |
|---|---|
Generics <T>
|
Reuse structure with a pluggable type. EntityDetail<IncomeCategory> — same idea as List<T> in Java |
interface extends |
Inherit shared fields from a base interface |
Optional chaining ?.
|
getIncome(1)?.amount — safe access when the value might be undefined
|
private / public
|
Same as Java — controls access from outside the class |
JavaScript / Node.js
| Topic | Key Takeaway |
|---|---|
sort() is destructive |
Array.sort() modifies in place — spread first to stay safe |
find |
Returns the first matching element, or undefined
|
| Variable shadowing | Callback args with the same name as outer vars hide the outer scope |
| Date validation |
new Date() + isNaN(getTime()) catches dates that are formatted correctly but don't exist |
Array Methods vs Java Streams
| TypeScript | Java | Purpose |
|---|---|---|
filter |
stream().filter() |
Keep elements matching a condition |
reduce |
stream().reduce() |
Collapse an array into a single value |
some |
stream().anyMatch() |
Return true if any element matches |
find |
stream().findFirst() |
Return the first matching element |
Jest Testing
| Topic | Key Takeaway |
|---|---|
beforeEach with class |
Create new AccountBook() before each test to keep tests independent |
expect().toBe() |
Strict equality check |
expect().toBeUndefined() |
Assert that a value is undefined
|
describe.skip |
Explicitly skip test groups that aren't meaningful to automate |
Reflection
Generics made sense once I had a reason to use them: I didn't design with EntityDetail<T> from the start. I wrote out both interfaces in full, felt the redundancy, and then asked AI if there was a better way. Refactoring after experiencing the pain made the concept stick much better than if I'd been told about it upfront.
Validation comes from running the code: Both the impossible-date gap and the missing category hints came from actually using the CLI and thinking "that's wrong." The feedback loop of building → using → improving is what I'm finding most valuable in these projects.
Starting with types pays off: Locking down types.ts before touching account.ts meant TypeScript caught mismatches early. This mirrors the interface-first approach I'd use in Java, and it works just as well here.
readline + async/await is next: The callback nesting in index.ts was readable this time, but I can see how it'll get messy as complexity grows. Rewriting with async/await is on my list for the next project.
Wrapping Up
This was my fourth TypeScript project — a Household Budget CLI.
Progress since the Todo List:
- Designed a shared generic interface (
EntityDetail<T>) from scratch - Moved from function-based to class-based design and understood the trade-offs
- Used
reduce,some, andfindin real logic — not just exercises - Wrote date validation that catches impossible dates, not just malformed ones
- Hit and diagnosed variable shadowing — a subtle bug category worth knowing
Next up: an external API tool (weather API) to learn async/await, fetch, and response type definitions.
Full learning log: LEARNING_LOG.md
This article is part of my public learning journey using AI tools (Claude Pro / Cursor Pro). All code is written by me — AI is used for design discussions, bug hints, and spec clarification only.
Top comments (0)