DEV Community

Uya
Uya

Posted on • Originally published at zenn.dev

Building a Todo List CLI with TypeScript — interface, Array Methods, and Jest

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 beforeEach in 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 } ]
Enter fullscreen mode Exit fullscreen mode

⚠️ 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
Enter fullscreen mode Exit fullscreen mode

Same as my previous projects — responsibilities separated by file.


Tech Stack

  • TypeScript
  • Node.js (readline module)
  • 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;
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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];
}
Enter fullscreen mode Exit fullscreen mode

Design decisions:

  • Spread instead of push: push mutates the original array. Returning a new array with [...todoItems, newItem] keeps the function pure and the data immutable.
  • reduce for ID generation: Using todoItems.length + 1 caused duplicate IDs after deletion. Using reduce to 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);
}
Enter fullscreen mode Exit fullscreen mode

"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
  );
}
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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);
  });
});
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

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

  • push mutates 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:

  1. Designed a multi-field data structure with interface on my own
  2. Used filter, map, reduce, and some in real logic
  3. Applied immutable patterns consistently throughout
  4. Understood async callback flow by hitting (and fixing) a real bug
  5. 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)