Wiring Up the UI with DOM Types
The data layer is complete. types.ts, api.ts, and quiz.ts are fully typed and compiled. Now wire them to the browser: ui.ts handles all DOM manipulation, and main.ts is the entry point that connects user events to app state.
ui.ts — the rendering module
Section titled “ui.ts — the rendering module”ui.ts receives data as typed arguments and writes to the DOM. It never reads from localStorage or the network — that separation keeps each module’s responsibility clear.
Screen management
Section titled “Screen management”import type { TriviaQuestion, HighScore, ScreenId } from './types.js';
export function showScreen(id: ScreenId): void { document.querySelectorAll<HTMLElement>('.screen').forEach(el => { el.hidden = true; }); const screen = document.getElementById(id); if (screen) screen.hidden = false;}ScreenId — the literal union from types.ts — constrains the parameter to the four IDs that exist in the HTML. querySelectorAll<HTMLElement> is a generic DOM method: the type argument tells TypeScript what element type the selector returns, enabling access to .hidden without a cast.
Rendering a question
Section titled “Rendering a question”export function renderQuestion(question: TriviaQuestion, index: number, total: number): void { const questionEl = document.getElementById('question-text') as HTMLParagraphElement; const progressEl = document.getElementById('progress') as HTMLSpanElement; const categoryEl = document.getElementById('category') as HTMLSpanElement; const answersEl = document.getElementById('answers') as HTMLDivElement;
progressEl.textContent = `Question ${index + 1} of ${total}`; categoryEl.textContent = question.category; questionEl.textContent = question.question;
answersEl.innerHTML = ''; question.allAnswers.forEach(answer => { const btn = document.createElement('button'); btn.className = 'answer-btn'; btn.textContent = answer; btn.dataset.answer = answer; answersEl.appendChild(btn); });}getElementById returns HTMLElement | null. The as HTMLParagraphElement casts narrow the type and assert the element is present — valid because the HTML is a known static file. Using .textContent instead of .innerHTML avoids XSS — user-supplied question text is set as plain text, not parsed as markup.
Marking answers
Section titled “Marking answers”export function markAnswer(selected: string, correct: string): void { document.querySelectorAll<HTMLButtonElement>('.answer-btn').forEach(btn => { btn.disabled = true; if (btn.dataset.answer === correct) btn.classList.add('correct'); else if (btn.dataset.answer === selected) btn.classList.add('incorrect'); });}querySelectorAll<HTMLButtonElement> narrows the element type so btn.disabled and btn.dataset are available without a cast. All buttons are disabled after an answer — preventing double-clicks. Module 04 (function types, DOM types).
Rendering results
Section titled “Rendering results”export function renderResult( score: number, total: number, isNewHigh: boolean, highScore: HighScore | null,): void { const scoreEl = document.getElementById('final-score') as HTMLParagraphElement; const badgeEl = document.getElementById('new-high-badge') as HTMLSpanElement; const highEl = document.getElementById('high-score-result') as HTMLParagraphElement;
scoreEl.textContent = `${score} / ${total} correct`; badgeEl.hidden = !isNewHigh;
if (highScore) { highEl.textContent = `Best: ${highScore.score}/${highScore.total} — ${highScore.date}`; highEl.hidden = false; } else { highEl.hidden = true; }}
export function renderStartHighScore(highScore: HighScore | null): void { const el = document.getElementById('start-high-score') as HTMLParagraphElement; if (highScore) { el.textContent = `Best: ${highScore.score}/${highScore.total} — ${highScore.date}`; el.hidden = false; } else { el.hidden = true; }}HighScore | null — if null, the high score section is hidden. Inside the if (highScore) block, TypeScript narrows to HighScore — accessing highScore.score without a check would be an error. Module 06 (narrowing).
main.ts — the entry point
Section titled “main.ts — the entry point”main.ts holds application state, queries DOM elements once at startup, and connects user events to the data layer.
import { fetchQuestions } from './api.js';import { QuizEngine } from './quiz.js';import { showScreen, renderQuestion, markAnswer, renderResult, renderStartHighScore } from './ui.js';import type { Difficulty } from './types.js';
let engine: QuizEngine | null = null;let awaitingNext = false;
const startBtn = document.getElementById('start-btn') as HTMLButtonElement;const difficultyEl = document.getElementById('difficulty') as HTMLSelectElement;const amountEl = document.getElementById('amount') as HTMLSelectElement;const answersEl = document.getElementById('answers') as HTMLDivElement;const nextBtn = document.getElementById('next-btn') as HTMLButtonElement;const playAgainBtn = document.getElementById('play-again-btn') as HTMLButtonElement;
startBtn.addEventListener('click', async () => { const difficulty = difficultyEl.value as Difficulty | 'any'; const amount = parseInt(amountEl.value, 10);
showScreen('loading-screen'); try { const questions = await fetchQuestions(amount, difficulty === 'any' ? undefined : difficulty); engine = new QuizEngine(questions); awaitingNext = false; nextBtn.hidden = true; nextBtn.textContent = 'Next'; showScreen('question-screen'); renderQuestion(engine.current!, engine.index, engine.total); } catch (err) { const message = err instanceof Error ? err.message : 'Unknown error'; alert(`Could not load questions: ${message}`); showScreen('start-screen'); }});
answersEl.addEventListener('click', event => { if (!engine || awaitingNext) return; const btn = (event.target as HTMLElement).closest<HTMLButtonElement>('.answer-btn'); if (!btn) return;
const selected = btn.dataset.answer ?? ''; const correctAnswer = engine.current!.correctAnswer; engine.answer(selected); markAnswer(selected, correctAnswer); awaitingNext = true;
nextBtn.textContent = engine.isDone ? 'See Results' : 'Next'; nextBtn.hidden = false;});
nextBtn.addEventListener('click', () => { if (!engine) return; awaitingNext = false;
if (engine.isDone) { const score = engine.currentScore; const total = engine.total; const isNew = QuizEngine.isNewHighScore(score, total); if (isNew) QuizEngine.saveHighScore(score, total); renderResult(score, total, isNew, QuizEngine.loadHighScore()); showScreen('result-screen'); } else { nextBtn.hidden = true; nextBtn.textContent = 'Next'; renderQuestion(engine.current!, engine.index, engine.total); }});
playAgainBtn.addEventListener('click', () => { engine = null; awaitingNext = false; renderStartHighScore(QuizEngine.loadHighScore()); showScreen('start-screen');});
renderStartHighScore(QuizEngine.loadHighScore());showScreen('start-screen');Key TypeScript patterns in main.ts:
engine: QuizEngine | null— nullable state variable, narrowed withif (!engine) returnbefore use. Module 06 (narrowing).as Difficulty | 'any'—difficultyEl.valueisstring; the cast narrows it for the conditional. Module 02 (literal types).err instanceof Error ? err.message : 'Unknown error'— safe error handling. Module 06 (instanceof).engine.current!— non-null assertion where we knowengineis active and not done. Used carefully.- Event delegation on
answersEl—closest<HTMLButtonElement>('.answer-btn')walks up the DOM tree. Module 04 (generics, DOM types).
Exercise
Section titled “Exercise”- Replace the
ui.tsandmain.tsstubs with the complete files above. - Run
npm run build— fix all errors. - Run
npm run serve— load the app and test the full flow: start a quiz, answer questions, see results, play again. - Open DevTools → Application → Local Storage — confirm a high score entry is written after completing a quiz.
querySelectorAll<HTMLElement>andgetElementById(...) as HTMLElementuse TypeScript’s generic DOM API to get typed element references.textContentfor user-provided strings — neverinnerHTML— prevents XSS.HighScore | nullparameters force callers to handle the absent case — TypeScript narrows toHighScoreafter the null check.engine: QuizEngine | nullcombined with early-return guards keeps event handlers safe without excessive null checks throughout.err instanceof Errorincatchblocks — caught errors areunknown; narrow before accessing.message.