Introduction
This is my fifth article as a Java engineer learning TypeScript from scratch.
In my previous article, I built a Household Budget CLI and learned about class, generics <T>, interface extends, and array methods like reduce, some, and find. This time, I built a Quiz CLI and focused on:
- Managing choice IDs safely with
enum - Constraining array length at the type level with tuple types
- Replacing callback hell with
async/await - Swapping out the
fsmodule entirely withjest.mock() - Understanding when to use
jest.spyOnvsjest.mock()
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 create, edit, delete, and randomly play quiz questions stored in a JSON file.
==============================
1. Add a quiz
2. Edit a quiz
3. Delete a quiz
4. Play quizzes (3 random questions)
5. Show all quizzes
6. Exit
> 1
Enter the question: What keyword defines a type alias in TypeScript?
Enter option 1: type
Enter option 2: interface
Enter the correct answer (1 or 2): 1
Quiz added.
> 4
--- Question 1 ---
What keyword defines a type alias in TypeScript?
1: type
2: interface
Your answer (1 or 2): 1
✅ Correct!
Result: 1 / 1 correct
==============================
Quiz data is saved to a JSON file and persists between sessions.
📦 Repository: https://github.com/uya0526-design/simple_quiz
Project Structure
simple_quiz/
├── src/
│ ├── index.ts # Entry point / CLI menu
│ ├── quiz.ts # Business logic (QuizManager class)
│ ├── types.ts # Type definitions
│ └── __tests__/
│ └── quiz.test.ts # Unit tests (10 cases)
├── quizzes.json # Persistent quiz data
├── jest.config.js
├── tsconfig.json
└── package.json
Same three-layer structure as the previous projects: types.ts (types) / quiz.ts (logic) / index.ts (UI).
New this time: JSON file persistence.
Tech Stack
- TypeScript
- Node.js (
readlineandfsmodules) - Jest + ts-jest (unit testing)
What I Implemented Myself
types.ts — Type Definitions
Two key ideas drove the type design this time: enum and tuple types.
enum to name choice IDs
The quiz is always two choices. My first instinct was to use plain number, but 1 and 2 on their own carry no meaning. enum lets me give those numbers names:
export enum OptionId {
One = 1,
Two = 2,
}
OptionId.One is immediately readable — you know it means "the first option." Same concept as a Java enum paired with an integer value.
Tuple types to fix array length
I first typed the options array as option[]. The problem: that accepts zero items, three items, or any count — but a quiz always has exactly two choices. A tuple locks that down:
export type option = {
id: OptionId;
text: string;
};
export interface Quiz {
id: number;
question: string;
options: [option, option]; // exactly 2 — enforced at compile time
answer: OptionId;
}
With [option, option], TypeScript rejects anything that isn't exactly two elements. option[] couldn't prevent that — tuple types can.
quiz.ts — The QuizManager Class
I decided to separate logic into a QuizManager class and keep index.ts as pure I/O. The previous household budget project taught me that this separation makes testing much easier — same principle as a Java Service layer vs. Controller layer.
export class QuizManager {
private quizzes: Quiz[] = [];
constructor() {}
loadQuizzes(): void {
if (!fs.existsSync(FILE_PATH)) {
fs.writeFileSync(FILE_PATH, JSON.stringify([], null, 2));
}
const data = fs.readFileSync(FILE_PATH, 'utf-8');
this.quizzes = JSON.parse(data);
}
saveQuizzes(): void {
fs.writeFileSync(FILE_PATH, JSON.stringify(this.quizzes, null, 2));
}
// ...
}
ID generation with reduce
Rather than keeping a nextId field in the stored data (which would pollute the JSON), I compute it from existing records:
private getNextQuizId(): number {
if (this.quizzes.length === 0) return 1;
return this.quizzes.reduce((maxId, quiz) => Math.max(maxId, quiz.id), 0) + 1;
}
Start at 0, walk through all IDs, take the max, add one. If a quiz is deleted and a new one is added, there are no ID collisions.
isQuizExists with some
isQuizExists(id: number): boolean {
return this.quizzes.some((quiz) => quiz.id === id);
}
Guard method used before editing or deleting. some returns true if at least one element matches — Java's Stream.anyMatch(). I'd used this pattern in the budget project too, so it came naturally.
index.ts — CLI Menu
async/await to escape callback nesting
The budget project's readline callbacks were readable, but I could already see how deeper nesting would make them hard to follow. This time I wrapped readline in a Promise from the start:
function askQuestion(prompt: string): Promise<string> {
return new Promise((resolve) => {
rl.question(prompt, (answer) => {
resolve(answer);
});
});
}
Then the main flow reads top-to-bottom:
// Callback style (nesting grows with each input)
rl.question('Question: ', (q) => {
rl.question('Option 1: ', (o1) => {
rl.question('Option 2: ', (o2) => {
// ...
});
});
});
// async/await (reads like sequential code)
const question = await askQuestion('Enter the question: ');
const option1 = await askQuestion('Enter option 1: ');
const option2 = await askQuestion('Enter option 2: ');
It feels similar to waiting on a Java Future with .get(), but without blocking a thread.
Random question selection
const randomQuizzes = quizManager.getQuizzes()
.sort(() => Math.random() - 0.5)
.slice(0, 3);
sort(() => Math.random() - 0.5) is a standard shuffle idiom — the comparator returns a random positive or negative value, randomizing the sort order. .slice(0, 3) then takes the first three.
quiz.test.ts — Unit Tests with Jest
The toughest part of testing this project was mocking the fs module.
jest.spyOn vs jest.mock()
I started with jest.spyOn(fs, 'readFileSync'). Immediate error:
Cannot redefine property: readFileSync
AI explained why: Node's fs module methods have configurable: false in their property descriptors. jest.spyOn works by redefining the property — which is forbidden when configurable is false.
The fix is to replace the entire module instead of patching individual methods:
// ❌ fs methods are configurable: false — spyOn can't redefine them
jest.spyOn(fs, 'readFileSync').mockReturnValue('...');
// ✅ jest.mock() replaces the whole module — no configurable restriction
jest.mock('fs');
const mockedFs = jest.mocked(fs);
Comparison:
jest.spyOn |
jest.mock() |
|
|---|---|---|
| Scope | One method | Entire module |
| Requirement | configurable: true |
No restriction |
| Reset | restoreAllMocks() |
resetAllMocks() |
| Java equivalent | Mockito.spy(realObject) |
Mockito.mock(ClassName.class) |
Note: console.error does work with jest.spyOn because console methods are configurable: true. Whether spyOn is usable depends entirely on how the object's properties are defined — not on which module it is.
The "accidentally passing test" trap
While writing the loadQuizzes test, I used JSON.stringify([]) in my assertion. It passed. AI flagged it:
// ❌ Accidentally passes — empty arrays produce "[]" either way
expect(mockedFs.writeFileSync).toHaveBeenCalledWith(
expect.stringContaining('quizzes.json'),
JSON.stringify([])
);
// ✅ Matches the actual call exactly
expect(mockedFs.writeFileSync).toHaveBeenCalledWith(
expect.stringContaining('quizzes.json'),
JSON.stringify([], null, 2)
);
JSON.stringify([]) and JSON.stringify([], null, 2) both return "[]" for an empty array. Once data is present, the second form adds newlines and indentation — and the test would fail. "Test passes" does not mean "test is correct."
expect.stringContaining() for cross-platform paths
File paths look different on Windows (\) vs Mac/Linux (/). Rather than hardcoding a full path, I match on the filename only:
expect(mockedFs.writeFileSync).toHaveBeenCalledWith(
expect.stringContaining('quizzes.json'),
// ...
);
Test results:
PASS src/__tests__/quiz.test.ts
loadQuizzes
✓ creates a new file if none exists
✓ loads data from an existing file
saveQuizzes
✓ can save quizzes
addQuiz
✓ can add a quiz
✓ second quiz gets ID 2
editQuiz
✓ can edit a quiz
deleteQuiz
✓ can delete a quiz
isQuizExists
✓ returns true when the quiz exists
✓ returns false when the quiz does not exist
getNextQuizId (tested indirectly via addQuiz)
✓ no ID collision after delete then add
Tests: 10 passed, 10 total
What I Asked AI For
| Topic | What AI helped with |
|---|---|
| Spec clarification | Identified unclear and missing requirements up front |
| async/await explanation | Compared it to callback style and to Java's Future |
| Tuple type hint | Suggested changing option[] to [option, option]
|
| Constructor issue | Caught that private constructor prevents external instantiation |
| editQuiz bug | Pointed out map(q => q.id === id ? q : q) does nothing |
| Jest error fix | Explained Cannot redefine property and suggested jest.mock()
|
| console.error mock | Showed how to use jest.spyOn(console, 'error')
|
| Path comparison | Suggested expect.stringContaining()
|
| README corrections | Fixed title and tech stack description |
Where I Got Stuck
1. private constructor prevented instantiation
Situation: Wrote private constructor() on QuizManager. Then new QuizManager() threw an error.
Root cause: private constructor is intentional — it's the pattern for preventing external instantiation (e.g., singletons). A regular class should just use constructor() (public by default).
// ❌ Can't be instantiated from outside the class
export class QuizManager {
private constructor() {}
}
// ✅ Public constructor — the normal case
export class QuizManager {
constructor() {}
}
Same rule applies in Java. A slip of habit.
2. editQuiz had a no-op map
Situation: After calling editQuiz, nothing changed. AI asked me to re-read the map callback.
Root cause: Both branches of the ternary returned the same value:
// ❌ Both branches return `quiz` — nothing is updated
this.quizzes = this.quizzes.map((quiz) =>
quiz.id === id ? quiz : quiz
);
// ✅ Return the updated object when IDs match
this.quizzes = this.quizzes.map((quiz) =>
quiz.id === id ? updatedQuiz : quiz
);
The intent was clear — the execution was wrong. The kind of bug that's invisible until you slow down and re-read the line.
3. let inside a switch case caused a scope error
Situation: let score = 0 inside case '6' caused a TypeScript error.
Root cause: switch cases share a single scope. let and const declarations in one case can conflict with those in others.
// ❌ All cases share one scope — let can conflict across cases
case '6':
let score = 0;
break;
// ✅ Wrap in braces to create a block scope
case '6': {
let score = 0;
break;
}
Wrapping in {} gives each case its own block scope. Simple fix once you know the cause.
4. console.error needed spyOn, not jest.mock()
Unlike fs, console.error works fine with jest.spyOn:
jest.spyOn(console, 'error').mockImplementation(() => {});
Because console properties are configurable: true. The lesson isn't "use jest.mock() for everything" — it's "know your target's property descriptor before choosing your approach."
What I Learned
async/await
- Wrapping
readlineinnew Promise((resolve) => { ... })makes itawait-able -
async/awaitturns nested callbacks into sequential, top-to-bottom code - Feels like Java's
CompletableFuture.get(), but non-blocking
TypeScript Types
| Topic | Key Takeaway |
|---|---|
enum |
Gives numeric values meaningful names. OptionId.One beats a raw 1
|
Tuple [A, B]
|
Enforces exact array length and element types at compile time |
Missing export
|
Any type or class used across files needs export — easy to forget |
Jest Mocking
| Topic | Key Takeaway |
|---|---|
jest.spyOn() |
Works when configurable: true — patches a single method |
jest.mock() |
Replaces the entire module — no configurable restriction |
jest.mocked(fn) |
Utility that tells TypeScript a function is already mocked |
jest.resetAllMocks() |
Clears mock implementations and call history |
Test Quality
- A test that passes is not necessarily a correct test
-
JSON.stringify([])andJSON.stringify([], null, 2)look different but both return"[]"for empty arrays -
expect.stringContaining()handles OS path differences without hardcoding separators - Write tests for error paths (
catchblocks), not just happy paths
Design
- Separating logic (
quiz.ts) from I/O (index.ts) makes the logic independently testable - Code that touches the filesystem during tests should be mocked out
- Same principle as Java's Service / Controller separation
Reflection
async/await built on last project's frustration: The budget project's readline callbacks were fine, but I could see the wall coming. Starting this project with async/await from day one felt like applying a lesson before being forced to — that's a good sign.
Tuple types clicked because I had a concrete need: "This quiz must always have exactly two options" is a real constraint. Expressing that constraint as [option, option] instead of option[] felt like TypeScript doing what it's supposed to do — enforcing things so I don't have to remember them.
Understanding configurable made jest.mock() make sense: If I'd just followed "use jest.mock() for fs" as a rule, I wouldn't have known why — or when to use spyOn instead. Now I know the underlying reason, and I can apply the right tool in future situations.
"Accidentally passing" tests are dangerous: A test that passes for the wrong reason gives false confidence. After this experience, I now double-check: does this test actually verify the behavior I care about, or did I get lucky?
Wrapping Up
This was my fifth TypeScript project — a Quiz CLI with CRUD and random playback.
Progress since the Household Budget:
- Used
async/awaitto eliminate callback nesting in readline - Constrained array length with tuple types —
[option, option]instead ofoption[] - Gave numeric IDs meaningful names with
enum - Understood the
configurableproperty descriptor and chosejest.mock()overjest.spyOnforfs - Discovered the "accidentally passing test" trap and learned to verify test correctness, not just test passage
Next up: an external API tool (weather API) to go deeper on fetch, async/await, 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)