Building the Quiz Engine with Classes
api.ts fetches and normalizes questions. quiz.ts manages the quiz session — tracking which question is current, whether the player answered correctly, and what the final score is. All mutable state lives here, behind an access-controlled interface.
The design
Section titled “The design”QuizEngine is constructed with a TriviaQuestion[] and exposes its state through typed getters. External code — main.ts — calls engine.answer(selected) to advance the quiz and reads engine.current, engine.isDone, and engine.currentScore to decide what to render. Nothing outside the class can directly modify _index or _score.
The static methods — loadHighScore, saveHighScore, isNewHighScore — are standalone utilities that belong conceptually with the quiz but do not need an instance.
Instance properties and constructor
Section titled “Instance properties and constructor”export class QuizEngine { private readonly questions: TriviaQuestion[]; private _index: number = 0; private _score: number = 0;
constructor(questions: TriviaQuestion[]) { this.questions = questions; }private readonly questions— assigned once in the constructor, never changeable. Module 05 (readonly).private _indexandprivate _score— mutable state, visible only within the class. Module 04 (access modifiers).- Inline defaults (
= 0) initialize both without constructor assignments. Module 04 (class properties).
Typed getters
Section titled “Typed getters”Getters expose controlled read access with explicit return types:
get total(): number { return this.questions.length; } get index(): number { return this._index; } get currentScore(): number { return this._score; } get isDone(): boolean { return this._index >= this.questions.length; }
get current(): TriviaQuestion | null { return this.questions[this._index] ?? null; }current returns TriviaQuestion | null — when _index is out of bounds, questions[_index] is undefined, and ?? null converts it to null. The explicit return type makes the null case visible to every caller. Module 06 (?? narrowing).
The answer method
Section titled “The answer method” answer(selected: string): boolean { const correct = this.current?.correctAnswer === selected; if (correct) this._score++; this._index++; return correct; }this.current?.correctAnswer uses optional chaining — if current is null (quiz already done), the expression short-circuits to undefined, and the comparison returns false. Calling answer after the quiz is done increments _index further, but isDone stays true — a harmless no-op.
Returns boolean — the caller uses this to decide whether to show a “correct” or “incorrect” visual.
Static localStorage methods
Section titled “Static localStorage methods” static loadHighScore(): HighScore | null { const raw = localStorage.getItem(STORAGE_KEY); if (!raw) return null; try { const parsed: unknown = JSON.parse(raw); return isHighScore(parsed) ? parsed : null; } catch { return null; } }
static saveHighScore(score: number, total: number): void { const entry: HighScore = { score, total, date: new Date().toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }), }; localStorage.setItem(STORAGE_KEY, JSON.stringify(entry)); }
static isNewHighScore(score: number, total: number): boolean { const existing = QuizEngine.loadHighScore(); if (!existing) return true; return score / total > existing.score / existing.total; }loadHighScore parses localStorage as unknown and validates with isHighScore before returning — the safe pattern from Module 06 (type predicates). No blind cast.
saveHighScore constructs a HighScore object inline — TypeScript checks that all three fields are present and correctly typed before JSON.stringify.
The isHighScore type predicate
Section titled “The isHighScore type predicate”Add this function above the class in quiz.ts:
function isHighScore(value: unknown): value is HighScore { return ( typeof value === 'object' && value !== null && 'score' in value && typeof (value as Record<string, unknown>).score === 'number' && 'total' in value && typeof (value as Record<string, unknown>).total === 'number' && 'date' in value && typeof (value as Record<string, unknown>).date === 'string' );}When this returns true, TypeScript narrows value to HighScore at the call site — enabling loadHighScore to return parsed directly. Module 06 (type predicates, in operator).
The complete quiz.ts
Section titled “The complete quiz.ts”import type { TriviaQuestion, HighScore } from './types.js';
const STORAGE_KEY = 'aceit_highscore';
function isHighScore(value: unknown): value is HighScore { return ( typeof value === 'object' && value !== null && 'score' in value && typeof (value as Record<string, unknown>).score === 'number' && 'total' in value && typeof (value as Record<string, unknown>).total === 'number' && 'date' in value && typeof (value as Record<string, unknown>).date === 'string' );}
export class QuizEngine { private readonly questions: TriviaQuestion[]; private _index: number = 0; private _score: number = 0;
constructor(questions: TriviaQuestion[]) { this.questions = questions; }
get total(): number { return this.questions.length; } get index(): number { return this._index; } get currentScore(): number { return this._score; } get isDone(): boolean { return this._index >= this.questions.length; }
get current(): TriviaQuestion | null { return this.questions[this._index] ?? null; }
answer(selected: string): boolean { const correct = this.current?.correctAnswer === selected; if (correct) this._score++; this._index++; return correct; }
static loadHighScore(): HighScore | null { const raw = localStorage.getItem(STORAGE_KEY); if (!raw) return null; try { const parsed: unknown = JSON.parse(raw); return isHighScore(parsed) ? parsed : null; } catch { return null; } }
static saveHighScore(score: number, total: number): void { const entry: HighScore = { score, total, date: new Date().toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }), }; localStorage.setItem(STORAGE_KEY, JSON.stringify(entry)); }
static isNewHighScore(score: number, total: number): boolean { const existing = QuizEngine.loadHighScore(); if (!existing) return true; return score / total > existing.score / existing.total; }}Exercise
Section titled “Exercise”- Replace the
quiz.tsstub with the complete file above. - Run
npm run build— fix any errors. - Test
QuizEnginein isolation in the browser console. Import it viawindowassignment and:- Construct an engine with a hardcoded question array.
- Call
engine.answer('wrong')— confirmisDoneandcurrentScoreupdate correctly. - Call
QuizEngine.saveHighScore(7, 10)— check Application → localStorage in DevTools. - Call
QuizEngine.loadHighScore()— confirm it returns the saved object.
private readonly questionsprevents the questions array from being replaced after construction.private _indexand_scoreare only accessible via the public getters — external code cannot put the engine in an inconsistent state.currentreturnsTriviaQuestion | null— thenullcase is explicit, forcing callers to handle it.isHighScoreis a type predicate — it validateslocalStoragedata asunknownbefore any cast.- Static methods belong to the class conceptually but do not require an instance.