Project: a command-line quiz
Build a multiple-choice quiz in four stages from a JSON question file, small ES modules, a validated input loop and error handling, then extend it.
What you will learn
- Split a program into modules that each do one job
- Load and validate data from a JSON module
- Read answers in a loop that rejects bad input
- Test each stage on its own and extend a finished program
On this page
You will build one program from what the beginner level has covered: arrays of objects, map and filter, destructuring, JSON modules, ES modules and error handling. The ideas behind handling errors are in the fundamentals lesson on errors and exceptions.
There is one new idea, in stage 4: reading what a person types. A program cannot know when the next line will arrive, so you give Node a function and it calls that function whenever a line comes in. That is a callback, the same kind of function you passed to map, used as an event listener. Node also has an await-based way to read input. This lesson does not use it, because async functions belong to the intermediate level.
The program is a multiple-choice quiz. It asks questions from a data file, one at a time, and keeps asking until it gets a valid answer. At the end it prints a score and a verdict.
The plan
- Questions live in a JSON file, so you can change them without touching code.
- Each module does one job:
load.mjsloads and checks the data,score.mjsdoes the arithmetic,ask.mjstalks to the user andmain.mjsjoins them. - Logic that does not read input (loading, scoring, parsing a choice) is kept in functions that you can run and check alone.
- Build it in stages. After each stage, run its small demo and check the output before you go on.
Make a folder called quiz and put every file in it. All the commands below run inside that folder.
Stage 1: the questions and a loader
Each question has a prompt, a list of choices and the index of the correct answer, counting from 0.
[ { "prompt": "Which keyword declares a constant?", "choices": ["var", "let", "const", "def"], "answer": 2 }, { "prompt": "What does typeof null return?", "choices": ["null", "object", "undefined", "number"], "answer": 1 }, { "prompt": "Which array method adds an item to the end?", "choices": ["push", "pop", "shift", "unshift"], "answer": 0 }, { "prompt": "Is 0.1 + 0.2 === 0.3 in JavaScript?", "choices": ["true", "false"], "answer": 1 }, { "prompt": "Which extension always makes Node run a file as an ES module?", "choices": [".mjs", ".cjs", ".json", ".txt"], "answer": 0 }]The loader imports the file as a JSON module, as in the previous lessons, and checks the shape of every question. A broken data file should stop the program with a clear message, not cause strange behaviour halfway through a game.
import questions from "./questions.json" with { type: "json" };
export function validate(list) { if (!Array.isArray(list) || list.length === 0) { throw new Error("The quiz needs at least one question"); } list.forEach((q, i) => { const where = `question ${i + 1}`; if (typeof q.prompt !== "string") { throw new Error(`${where}: prompt must be text`); } if (!Array.isArray(q.choices) || q.choices.length < 2) { throw new Error(`${where}: needs at least two choices`); } const { answer, choices } = q; if (!Number.isInteger(answer) || answer < 0 || answer >= choices.length) { throw new Error(`${where}: answer is not one of the choices`); } }); return list;}validate throws an Error that names the question number and the problem. The loader then exposes it through one small function.
export function loadQuestions() { return validate(questions);}loadQuestions validates the imported data and returns it. Exporting validate as well lets the demo try it with bad data.
Test it with check_data.mjs:
import { loadQuestions, validate } from "./load.mjs";
const questions = loadQuestions();console.log(`${questions.length} questions loaded`);console.log(questions[0].prompt);
try { validate([{ prompt: "?", choices: ["only one"], answer: 0 }]);} catch (error) { console.log(error.message);}Run node check_data.mjs:
5 questions loadedWhich keyword declares a constant?question 1: needs at least two choicesThe data loads, and the bad question is reported by number. Stage 1 is done.
Stage 2: scoring
The scoring needs no input or output at all, which makes it easy to check.
export function isCorrect(question, picked) { return picked === question.answer;}
export function summarise(results) { const correct = results.filter(Boolean).length; const total = results.length; const percent = total === 0 ? 0 : Math.round((correct / total) * 100); return { correct, total, percent };}
export function verdict(percent) { if (percent >= 80) return "Excellent!"; if (percent >= 50) return "Good effort."; return "Keep practising.";}isCorrectcompares the chosen index with the stored answer.summarisetakes a list oftrueandfalseresults.filter(Boolean)keeps thetrueones, and the percentage is rounded. The check for an empty list avoids dividing by zero.verdictturns a percentage into a message.
import { isCorrect, summarise, verdict } from "./score.mjs";
const question = { prompt: "?", choices: ["a", "b"], answer: 1 };console.log(isCorrect(question, 1), isCorrect(question, 0));
console.log(summarise([true, false, true, true]));console.log(summarise([]));console.log(verdict(100), verdict(50), verdict(0));Run node score_demo.mjs:
true false{ correct: 3, total: 4, percent: 75 }{ correct: 0, total: 0, percent: 0 }Excellent! Good effort. Keep practising.Three of four is 75 percent. The empty list gives 0 percent and not NaN. Stage 2 is done.
Stage 3: showing questions and checking answers
This module does not read any input. It shows a question and turns the typed text into an answer. Keeping it free of input makes it as easy to check as the scoring.
export function parseChoice(text, count) { const trimmed = text.trim(); const number = Number(trimmed); if (trimmed === "" || !Number.isInteger(number)) return null; if (number < 1 || number > count) return null; return number - 1;}
export function showPrompt() { process.stdout.write("Your answer: ");}
export function showQuestion(question, number) { console.log(`\nQuestion ${number}: ${question.prompt}`); question.choices.forEach((choice, i) => console.log(` ${i + 1}) ${choice}`)); showPrompt();}parseChoiceturns the typed text into a zero-based index, ornullwhen the text is not a whole number from 1 to the number of choices. It checks for an empty string first, becauseNumber("")is0. It usesNumber.isInteger, so"1.5"is rejected.showQuestionprints the question with numbered choices, then the prompt. The user sees the numbers 1 to 4, but the program works with indexes from 0.showPromptwritesYour answer:without a newline, so the cursor stays on the same line.
Check it with a demo:
import { parseChoice, showQuestion } from "./ask.mjs";
console.log(parseChoice("2", 4), parseChoice(" 4 ", 4), parseChoice("5", 4));console.log(parseChoice("abc", 4), parseChoice("", 4), parseChoice("1.5", 4));
const question = { prompt: "Pick the second one", choices: ["a", "b", "c"], answer: 1,};showQuestion(question, 1);console.log("(typed answer goes here)");Run node ask_demo.mjs:
1 3 nullnull null null
Question 1: Pick the second one 1) a 2) b 3) cYour answer: (typed answer goes here)The first two lines are the parseChoice checks. Then comes one question as the player would see it. Stage 3 is done.
Stage 4: the main program
main.mjs brings the modules together. It loads and checks the questions, asks each one, records whether the answer was right and prints the result.
Reading from the keyboard is different from everything before it, because the program has to wait for a person. createInterface from node:readline gives you a line reader. Two calls to rl.on register functions for it:
rl.on("line", fn)callsfn(text)each time the person presses Enter. The function runs once per line, so anything that must survive between lines, such as the list of results, lives outside it.rl.on("close", fn)callsfnwhen the input ends: afterrl.close(), at the end of piped input, or when the person presses Ctrl+D (Ctrl+Z then Enter on Windows).
The program is built in five blocks. First the setup, which shows the first question:
import { createInterface } from "node:readline";import { loadQuestions } from "./load.mjs";import { parseChoice, showPrompt, showQuestion } from "./ask.mjs";import { isCorrect, summarise, verdict } from "./score.mjs";
function runQuiz(questions) { const rl = createInterface({ input: process.stdin }); const results = [];
console.log(`JavaScript quiz: ${questions.length} questions`); showQuestion(questions[0], 1);The next block is the start of the line listener. It ignores any line that arrives after the last answer, checks the typed text, and asks again when it is not a valid choice. Because the listener runs once per line, the number of results so far is also the index of the current question.
rl.on("line", (text) => { if (results.length === questions.length) return;
const question = questions[results.length]; const picked = parseChoice(text, question.choices.length); if (picked === null) { console.log(`Please type a number from 1 to ${question.choices.length}.`); showPrompt(); return; }The rest of the listener scores a valid answer and either closes the reader after the last question or shows the next one.
const right = isCorrect(question, picked); const answer = question.choices[question.answer]; console.log(right ? "Correct!" : `Not quite. The answer is ${answer}.`); results.push(right);
if (results.length === questions.length) { rl.close(); } else { showQuestion(questions[results.length], results.length + 1); } });The close listener prints the score. It also covers input that ends early.
rl.on("close", () => { if (results.length < questions.length) console.log("\nInput ended early."); const { correct, total, percent } = summarise(results); console.log(`\nScore: ${correct}/${total} (${percent}%)`); console.log(verdict(percent)); });}Last, the start of the program. Any error thrown inside the try block, such as a bad data file, is reported with console.error. process.exitCode = 1 sets the number the program reports when it ends: 0 means success and anything else means failure. Setting it marks the run as failed without cutting the program off.
try { runQuiz(loadQuestions());} catch (error) { console.error(`Quiz failed: ${error.message}`); process.exitCode = 1;}Run node main.mjs and answer as shown. Typing abc and 9 first shows the retry, then 3 is correct for question 1. The middle questions are left out here, and the rest follows the same pattern:
JavaScript quiz: 5 questions
Question 1: Which keyword declares a constant? 1) var 2) let 3) const 4) defYour answer: abcPlease type a number from 1 to 4.Your answer: 9Please type a number from 1 to 4.Your answer: 3Correct!
Question 2: What does typeof null return? 1) null 2) object 3) undefined 4) numberYour answer: 2Correct!
...
Question 5: Which extension always makes Node run a file as an ES module? 1) .mjs 2) .cjs 3) .json 4) .txtYour answer: 1Correct!
Score: 4/5 (80%)Excellent!The program is complete. To try the error path, change one answer in questions.json to 9 and run it again: it prints Quiz failed: question 1: answer is not one of the choices and exits with a failure code. To try the early end, press Ctrl+D after two answers: the close listener prints Input ended early. and the score for the answers so far.
Common pitfalls
- Treating typed input as a number.
readlinegives text.Number("")is0, so check for an empty string before you convert. - Showing 1-based numbers and storing 0-based indexes. Convert in one place,
parseChoice, and nowhere else. - Keeping state inside the line listener. The listener runs once per line, so a variable declared inside it starts again each time. Keep
resultsoutside. - Assuming no lines arrive after
rl.close(). Piped input can still deliver them, which is why the listener starts with a guard. - Putting everything in
main.mjs. The small modules could be checked without typing answers. - Forgetting
rl.close()after the last question. The program would keep waiting for input and not exit. - Validating late. Check data when it is loaded, so errors name the real cause.
Exercises
After the score, print the prompt and correct answer of every question the user got wrong. Do not change ask.mjs, load.mjs or score.mjs. Work on a copy of main.mjs named main_review.mjs.
Expected result: with the answers 3, 1, 1, 2, 1 the program gets only question 2 wrong, and the output ends with:
Score: 4/5 (80%)Excellent!
Review these:- What does typeof null return? -> objectShow solution
Only three places in main.mjs change. Declare a second array next to results:
const results = []; const missed = [];Fill it when an answer is wrong, right after results.push(right):
results.push(right); if (!right) missed.push(question);Print it at the end of the close listener, after the verdict:
if (missed.length > 0) { console.log("\nReview these:"); for (const q of missed) { console.log(`- ${q.prompt} -> ${q.choices[q.answer]}`); } }Everything else, including the error handling in the try block, stays as it was. Feed it the five answers and the last lines are the ones in the prompt above.
Write shuffle.mjs with a function shuffle(list, random = Math.random) that returns a shuffled copy and leaves the original alone. The random parameter lets you pass in a fake random function so the result can be checked. Then show it working.
Expected result: shuffle([1, 2, 3, 4, 5], () => 0) returns [ 2, 3, 4, 5, 1 ] and the original array is unchanged. With the real Math.random the copy still holds the same five numbers.
Show solution
This is the Fisher-Yates shuffle. It walks backwards through a copy and swaps each item with a random earlier one.
export function shuffle(list, random = Math.random) { const copy = [...list]; for (let i = copy.length - 1; i > 0; i--) { const j = Math.floor(random() * (i + 1)); [copy[i], copy[j]] = [copy[j], copy[i]]; } return copy;}import { shuffle } from "./shuffle.mjs";
const original = [1, 2, 3, 4, 5];
const fixed = shuffle(original, () => 0);console.log(fixed, original);
const real = shuffle(original);console.log(real.length, [...real].sort().join(""));[ 2, 3, 4, 5, 1 ] [ 1, 2, 3, 4, 5 ]5 12345With a random that always returns 0, the result is predictable, and the original is unchanged. With the real Math.random, the length stays 5 and the sorted items are the same five. To use it in the quiz, call shuffle(loadQuestions()) in main.mjs.
Going further
Pick one and build it. Ask for the number of questions on the command line with process.argv, the array of command-line words from the package lesson. Add a category to each question and report the score per category. Or load a second questions file chosen by name with import(), as in the dynamic import lesson. Keep each new piece in its own module.
Summary
- The quiz joined modules, JSON data, arrays of objects, validated input and error handling in one program.
- Splitting by job (
load,score,ask,main) let each stage be run and checked on its own. - Validate data when it is loaded, validate input in a loop, and report failures with a clear message and a failure exit code.
- Passing dependencies in (the random function) keeps modules simple to test.
- Reading input is event-driven: register a function with
rl.on("line", ...)and keep your state outside it.
That is the end of the core beginner modules on data and projects. The next five lessons are an optional strand on JavaScript in the browser, starting with JavaScript in a web page. If you only want Node.js, you have finished the beginner level, and the intermediate level continues from here.