Introduction
This is my third article as a Java engineer learning TypeScript from scratch.
In my previous article, I built a Rock-Paper-Scissors CLI and learned about union types, conditional logic, and Jest. This time, I built a Todo List CLI and focused on:
- Defining data structures with interface (the TypeScript equivalent of a Java POJO class)
- Using array methods (
filter/map/reduce/some) and comparing them with Java Streams - Writing immutable operations with the spread operator instead of
push - Understanding async flow in readline — callbacks and recursive calls
- Managing test setup with
beforeEachin Jest
Same as before — I write honestly about where I got stuck, what I figured out, and what I asked AI to help with.
My Learning Style (AI Transparency)
I use Claude Pro (for design discussions and Q&A) and Cursor Pro (for coding support) as learning companions.
However, I follow these rules for myself:
- 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 Todo list that manages title, priority, and completion status.
$ npm start
Select a menu option:
1. Add Todo
2. Delete Todo
3. Toggle completion
4. Show list
0. Exit
1
Enter a title:
Learn TypeScript
Select priority:
1. High
2. Medium
3. Low
1
Todo added!
[ { id: 1, title: 'Learn TypeScript', priority: 'high', completed: false } ]
⚠️ The list resets when you exit — no persistence. This is purely a learning project.
📦 Repository: https://github.com/uya0526-design/todo_list
Project Structure
todo_list/
├── src/
│ ├── index.ts # Entry point / CLI menu
│ ├── todo.ts # Todo operation logic
│ ├── types.ts # Type definitions
│ └── __tests__/
│ └── todo.test.ts # Unit tests (12 cases)
├── jest.config.js
├── package.json
└── tsconfig.json
Same as my previous projects — responsibilities separated by file.
Tech Stack
- TypeScript
- Node.js (
readlinemodule) - Jest + ts-jest (unit testing)
What I Implemented Myself
types.ts — Type Definitions
export type Priority = 'high' | 'medium' | 'low';
export interface TodoItem {
id: number;
title: string;
priority: Priority;
completed: boolean;
}
How I used interface vs type:
Initially, I embedded the priority options as an inline union type inside the interface:
// First version (inline)
export interface TodoItem {
id: number;
title: string;
priority: 'high' | 'medium' | 'low'; // embedded directly
completed: boolean;
}
As I built out index.ts, I realized I needed to reference the priority values there too. So I refactored Priority into its own named type alias — a decision I made myself, without being prompted.
Naming the type makes the intent clearer: this value represents a priority level, not just a random string.
todo.ts — Todo Operation Logic
addTodoItem function
import type { TodoItem, Priority } from './types.js';
export function addTodoItem(
todoItems: TodoItem[],
title: string,
priority: Priority
): TodoItem[] {
const maxId = todoItems.reduce((max, item) => Math.max(max, item.id), 0);
const newItem: TodoItem = { id: maxId + 1, title, priority, completed: false };
return [...todoItems, newItem];
}
Design decisions:
-
Spread instead of
push:pushmutates the original array. Returning a new array with[...todoItems, newItem]keeps the function pure and the data immutable. -
reducefor ID generation: UsingtodoItems.length + 1caused duplicate IDs after deletion. Usingreduceto find the current max ID solves that.
This was the first time I used reduce in real logic. The mental model — "collapse an array into a single value" — maps directly to Java's Stream.reduce().
deleteTodoItem function
export function deleteTodoItem(todoItems: TodoItem[], id: number): TodoItem[] {
return todoItems.filter((item) => item.id !== id);
}
"Keep everything except the one to remove" — clean and immutable. The original array is never touched.
toggleTodoItem function
export function toggleTodoItem(todoItems: TodoItem[], id: number): TodoItem[] {
return todoItems.map((item) =>
item.id === id ? { ...item, completed: !item.completed } : item
);
}
What { ...item, completed: !item.completed } does:
Instead of doing item.completed = !item.completed (which mutates), this creates a new object: spread all properties from item, then override only completed. The original object is untouched.
This style felt unfamiliar coming from Java, but once I saw it as "copy and override," it clicked.
index.ts — CLI Menu
The main loop uses readline + switch, with recursion to keep the menu running.
import * as readline from 'readline';
import { addTodoItem, deleteTodoItem, toggleTodoItem } from './todo.js';
import type { TodoItem, Priority } from './types.js';
let todoItems: TodoItem[] = [];
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
function main(): void {
console.log('Select a menu option:');
console.log('1. Add Todo');
console.log('2. Delete Todo');
console.log('3. Toggle completion');
console.log('4. Show list');
console.log('0. Exit');
rl.question('', (input) => {
const choice = parseInt(input, 10);
if (isNaN(choice)) {
console.log('Please enter a number.');
return main();
}
switch (choice) {
case 1:
addTodo();
break;
case 2:
deleteTodo();
break;
case 3:
toggleTodo();
break;
case 4:
console.log(todoItems);
main();
break;
case 0:
rl.close();
break;
default:
console.log('Invalid input.');
main();
break;
}
});
}
main();
Why let instead of const for todoItems:
Since my Todo functions return new arrays (immutable pattern), I need to reassign the variable with the returned value. const doesn't allow reassignment, so let is needed here.
// ❌ const doesn't allow reassignment
const todoItems: TodoItem[] = [];
todoItems = addTodoItem(todoItems, title, priority); // Error
// ✅ let allows reassignment
let todoItems: TodoItem[] = [];
todoItems = addTodoItem(todoItems, title, priority);
todo.test.ts — Unit Tests with Jest
Using beforeEach to reset state before each test:
import { addTodoItem, deleteTodoItem, toggleTodoItem } from '../todo';
describe('addTodoItem', () => {
let todoItems = addTodoItem([], 'test', 'high');
beforeEach(() => {
todoItems = addTodoItem([], 'test', 'high');
});
test('adding a Todo increases the count by 1', () => {
expect(todoItems).toHaveLength(1);
});
test('added Todo has id 1', () => {
expect(todoItems[0].id).toBe(1);
});
test('added Todo has the correct title', () => {
expect(todoItems[0].title).toBe('test');
});
test('added Todo has the correct priority', () => {
expect(todoItems[0].priority).toBe('high');
});
test('added Todo has completed = false', () => {
expect(todoItems[0].completed).toBe(false);
});
test('adding after delete does not produce a duplicate ID', () => {
const after = addTodoItem(deleteTodoItem(todoItems, 1), 'test2', 'low');
expect(after[0].id).toBe(1);
});
});
describe('deleteTodoItem', () => {
let todoItems: ReturnType<typeof addTodoItem>;
beforeEach(() => {
todoItems = addTodoItem(addTodoItem([], 'test1', 'high'), 'test2', 'low');
});
test('deleting a Todo decreases the count by 1', () => {
expect(deleteTodoItem(todoItems, 1)).toHaveLength(1);
});
test('deleted Todo no longer exists in the list', () => {
const result = deleteTodoItem(todoItems, 1);
expect(result.find((item) => item.id === 1)).toBeUndefined();
});
test('original array is unchanged after delete (immutability)', () => {
deleteTodoItem(todoItems, 1);
expect(todoItems).toHaveLength(2);
});
});
describe('toggleTodoItem', () => {
let todoItems: ReturnType<typeof addTodoItem>;
beforeEach(() => {
todoItems = addTodoItem([], 'test', 'high');
});
test('toggling changes completed from false to true', () => {
expect(toggleTodoItem(todoItems, 1)[0].completed).toBe(true);
});
test('toggling twice returns completed to false', () => {
const toggled = toggleTodoItem(todoItems, 1);
expect(toggleTodoItem(toggled, 1)[0].completed).toBe(false);
});
test('toggling one item does not affect others', () => {
const items = addTodoItem(todoItems, 'test2', 'low');
expect(toggleTodoItem(items, 1)[1].completed).toBe(false);
});
});
Test results:
PASS src/__tests__/todo.test.ts
addTodoItem
✓ adding a Todo increases the count by 1
✓ added Todo has id 1
✓ added Todo has the correct title
✓ added Todo has the correct priority
✓ added Todo has completed = false
✓ adding after delete does not produce a duplicate ID
✓ deleting a Todo decreases the count by 1
✓ deleted Todo no longer exists in the list
✓ original array is unchanged after delete (immutability)
✓ toggling changes completed from false to true
✓ toggling twice returns completed to false
✓ toggling one item does not affect others
Tests: 12 passed, 12 total
What I Asked AI For
| Topic | What AI helped with |
|---|---|
| interface syntax | The basic export interface Name { ... } form and PascalCase convention |
| push → spread | The concept of immutability and the [...arr, newItem] approach |
Recursive main() issue |
Explained that rl.question is async — calling main() outside the callback runs immediately |
switch fallthrough |
Pointed out that missing break / return causes the next case to run |
Processing after isNaN
|
Noted that without return, code after the check still executes |
| Jest setup | Explained npm install --save-dev jest ts-jest @types/jest and what each package does |
beforeEach usage |
Explained the syntax, compared to JUnit's @BeforeEach
|
| Test coverage ideas | Suggested what edge cases to verify for deleteTodoItem and toggleTodoItem
|
Where I Got Stuck
1. Adding after deletion caused duplicate IDs
Situation: After deleting the item with id: 1, adding a new item also got id: 1.
Root cause: My original ID logic was todoItems.length + 1. After a deletion, the array length decreases — so the new ID could collide with one that already exists elsewhere.
// ❌ Length-based (breaks after deletion)
const newId = todoItems.length + 1;
// ✅ Max ID-based (always safe)
const maxId = todoItems.reduce((max, item) => Math.max(max, item.id), 0);
const newId = maxId + 1;
Insight: I only caught this because I wrote a test for it. Without that test case, I might not have noticed until it caused a real bug.
2. Calling main() outside the rl.question callback
Situation: I wanted to show the menu again after each selection, so I initially wrote:
// ❌ Runs immediately, doesn't wait for input
rl.question('', (input) => {
// ... handle input
});
main(); // called outside the callback
Root cause: rl.question() is asynchronous — it registers a callback and moves on immediately. Calling main() outside the callback means it executes right away, before the user even answers.
Fix: Move every main() call inside the callback, at the end of each case branch.
3. Missing break in the default case
Situation: When a user entered an invalid option, main() was called (to show the menu again), but then execution fell through into case 0 and closed the program.
Root cause: No break after the default block. JavaScript's switch falls through to the next case just like Java — you need break or return to stop it.
// ❌ Falls through to case 0
default:
console.log('Invalid input.');
main();
// execution continues into case 0...
// ✅ Stops here
default:
console.log('Invalid input.');
main();
break;
I know this rule from Java, but it's easy to forget in the heat of writing logic.
4. No error when specifying a non-existent ID
Situation: Trying to delete or toggle an ID that didn't exist produced no error and no feedback — the operation just silently did nothing.
Fix: Added an existence check using some():
if (!todoItems.some((item) => item.id === id)) {
console.log('No Todo found with that ID.');
return main();
}
some() returns true if at least one element matches the condition — equivalent to Java's Stream.anyMatch().
What I Learned
TypeScript Type System
| Topic | Key Takeaway |
|---|---|
interface |
Defines the shape of data — similar to a Java POJO class |
type alias |
Gives a name to a union type for reuse across files |
| Shorthand properties |
{ title: title } can be written as { title }
|
Array Methods vs Java Streams
| TypeScript | Java | Purpose |
|---|---|---|
filter |
stream().filter() |
Keep elements that match a condition |
map |
stream().map() |
Transform every element |
reduce |
stream().reduce() |
Collapse an array into a single value |
some |
stream().anyMatch() |
Return true if any element matches |
Immutable Operations
-
pushmutates the original array → use[...arr, newItem]to return a new one - Partial object updates → use
{ ...obj, field: newValue }to return a new object - Functions that return new values instead of mutating are easier to test
Async Flow with readline
-
rl.question()registers a callback and returns immediately - To loop the menu, call
main()recursively inside the callback — not outside
Jest Testing
| Topic | Key Takeaway |
|---|---|
beforeEach |
Runs before each test — equivalent to JUnit's @BeforeEach
|
toHaveLength(n) |
Asserts array length |
toBeUndefined() |
Asserts a value is undefined
|
| Immutability testing | After an operation, verify the original array hasn't changed |
Reflection
Where my Java background helped: The Java Stream API background made filter, map, reduce, and some immediately intuitive — I could connect each one to its Java counterpart. My quality-first instincts also kicked in: I wrote tests for "does deletion preserve IDs?" and "does the operation stay immutable?" without being asked.
Biggest new concept: Async readline. Coming from Java's synchronous execution model, the idea that code after rl.question() runs before the callback was genuinely surprising. Hitting the bug and debugging it was the best way to understand it.
Self-initiated refactor: Extracting Priority into its own type alias was my own call. I noticed the need when building index.ts, and made the change without prompting — that kind of design awareness feels like real progress.
Wrapping Up
This was my third TypeScript project — a Todo List CLI.
Progress since the Rock-Paper-Scissors game:
- Designed a multi-field data structure with
interfaceon my own - Used
filter,map,reduce, andsomein real logic - Applied immutable patterns consistently throughout
- Understood async callback flow by hitting (and fixing) a real bug
- Managed test state properly with
beforeEach
Next up: persistent storage (writing to a file) and deeper async patterns.
The full learning log is in 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)