Skip to content

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 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.

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.

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.

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).

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 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 with if (!engine) return before use. Module 06 (narrowing).
  • as Difficulty | 'any'difficultyEl.value is string; 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 know engine is active and not done. Used carefully.
  • Event delegation on answersElclosest<HTMLButtonElement>('.answer-btn') walks up the DOM tree. Module 04 (generics, DOM types).
  1. Replace the ui.ts and main.ts stubs with the complete files above.
  2. Run npm run build — fix all errors.
  3. Run npm run serve — load the app and test the full flow: start a quiz, answer questions, see results, play again.
  4. Open DevTools → Application → Local Storage — confirm a high score entry is written after completing a quiz.
  • querySelectorAll<HTMLElement> and getElementById(...) as HTMLElement use TypeScript’s generic DOM API to get typed element references.
  • textContent for user-provided strings — never innerHTML — prevents XSS.
  • HighScore | null parameters force callers to handle the absent case — TypeScript narrows to HighScore after the null check.
  • engine: QuizEngine | null combined with early-return guards keeps event handlers safe without excessive null checks throughout.
  • err instanceof Error in catch blocks — caught errors are unknown; narrow before accessing .message.