Skip to content

Fetching and Typing API Data

With types in place, implement api.ts — the module responsible for fetching questions from the Open Trivia DB and transforming the raw response into the normalized TriviaQuestion shape the app uses.

  • fetchJson<T> — a generic fetch wrapper that returns Promise<T> (Module 05)
  • decodeHtml — decodes HTML entities in API strings (Module 04)
  • shuffle<T> — shuffles an array without mutating the original (Module 05)
  • normalizeQuestion — converts RawQuestionTriviaQuestion (Modules 03, 04)
  • fetchQuestions — builds the URL, fetches, validates, and maps (Modules 04, 05, 06)
export async function fetchJson<T>(url: string): Promise<T> {
const response = await fetch(url);
if (!response.ok) throw new Error(`HTTP ${response.status}: ${response.statusText}`);
return response.json() as Promise<T>;
}

The one as Promise<T> cast lives here — at the boundary with the untyped browser fetch API. Every caller gets a fully typed result with no cast of their own. fetchJson<ApiResponse>(url) returns Promise<ApiResponse>.

The Open Trivia DB returns HTML-encoded strings — ampersands become &amp;, quotes become &quot;. A textarea element decodes them reliably across all browsers:

function decodeHtml(html: string): string {
const el = document.createElement('textarea');
el.innerHTML = html;
return el.value;
}

Parameter: string. Return: string. The DOM library (lib: ["ES2020", "DOM"]) makes document.createElement available to the type checker.

The Module 04 version only worked with string[]. The generic version works with any array type:

function shuffle<T>(arr: T[]): T[] {
return [...arr].sort(() => Math.random() - 0.5);
}

T is inferred from the input. The spread ([...arr]) creates a copy before sorting — the original array is not mutated. normalizeQuestion passes string[]; TypeScript infers T as string.

normalizeQuestion takes the API shape and returns the app shape — converting snake_case to camelCase, decoding HTML entities, and shuffling the answer options:

function normalizeQuestion(raw: RawQuestion): TriviaQuestion {
return {
question: decodeHtml(raw.question),
category: decodeHtml(raw.category),
difficulty: raw.difficulty as Difficulty,
correctAnswer: decodeHtml(raw.correct_answer),
allAnswers: shuffle([raw.correct_answer, ...raw.incorrect_answers]).map(decodeHtml),
};
}

raw.difficulty as Difficulty is a narrow cast — the API returns 'easy', 'medium', or 'hard' as strings, which TypeScript infers as string. The cast narrows it to the Difficulty literal union. If the API ever returns an unexpected value, the cast would be wrong — a type predicate on difficulty would catch it, but for this project the API contract is trusted.

allAnswers combines the one correct and three incorrect answers into a shuffled array of four. This is the array the UI renders as answer buttons.

const BASE_URL = 'https://opentdb.com/api.php';
export async function fetchQuestions(
amount: number = 10,
difficulty?: Difficulty,
): Promise<TriviaQuestion[]> {
let url = `${BASE_URL}?amount=${amount}&type=multiple`;
if (difficulty) url += `&difficulty=${difficulty}`;
const data = await fetchJson<ApiResponse>(url);
if (data.response_code === ResponseCode.NoResults) {
throw new Error('Not enough questions available. Try a different setting.');
}
if (data.response_code !== ResponseCode.Success) {
throw new Error(`API error (code ${data.response_code})`);
}
return data.results.map(normalizeQuestion);
}
  • amount = 10 — default parameter from Module 04.
  • difficulty? — optional parameter, Difficulty | undefined. Appended only when present.
  • fetchJson<ApiResponse> — generic call from Module 05. data is fully typed as ApiResponse.
  • ResponseCode.NoResults and ResponseCode.Success — enum members from Module 02.
  • data.results.map(normalizeQuestion) — returns TriviaQuestion[]. TypeScript infers this from the return type of normalizeQuestion.
import type { ApiResponse, RawQuestion, TriviaQuestion, Difficulty } from './types.js';
import { ResponseCode } from './types.js';
const BASE_URL = 'https://opentdb.com/api.php';
export async function fetchJson<T>(url: string): Promise<T> {
const response = await fetch(url);
if (!response.ok) throw new Error(`HTTP ${response.status}: ${response.statusText}`);
return response.json() as Promise<T>;
}
function decodeHtml(html: string): string {
const el = document.createElement('textarea');
el.innerHTML = html;
return el.value;
}
function shuffle<T>(arr: T[]): T[] {
return [...arr].sort(() => Math.random() - 0.5);
}
function normalizeQuestion(raw: RawQuestion): TriviaQuestion {
return {
question: decodeHtml(raw.question),
category: decodeHtml(raw.category),
difficulty: raw.difficulty as Difficulty,
correctAnswer: decodeHtml(raw.correct_answer),
allAnswers: shuffle([raw.correct_answer, ...raw.incorrect_answers]).map(decodeHtml),
};
}
export async function fetchQuestions(
amount: number = 10,
difficulty?: Difficulty,
): Promise<TriviaQuestion[]> {
let url = `${BASE_URL}?amount=${amount}&type=multiple`;
if (difficulty) url += `&difficulty=${difficulty}`;
const data = await fetchJson<ApiResponse>(url);
if (data.response_code === ResponseCode.NoResults) {
throw new Error('Not enough questions available. Try a different setting.');
}
if (data.response_code !== ResponseCode.Success) {
throw new Error(`API error (code ${data.response_code})`);
}
return data.results.map(normalizeQuestion);
}
  1. Replace the api.ts stub with the complete file above.
  2. Run npm run build — fix any errors before moving on.
  3. In the browser DevTools console, after loading the page, manually call fetchQuestions:
    // Modules are not accessible from the console directly — add a temporary window assignment in main.ts:
    // import { fetchQuestions } from './api.js';
    // (window as any).fetchQuestions = fetchQuestions;
    Call await window.fetchQuestions(5, 'easy') and inspect the result. Confirm each item has question, correctAnswer, and allAnswers with four strings.
  4. Remove the window assignment after testing.
  • fetchJson<T> carries the expected type through without requiring casts at call sites — one cast at the boundary, zero downstream.
  • decodeHtml uses a textarea element — the most reliable cross-browser HTML entity decoder.
  • shuffle<T> uses a spread to avoid mutating the input array.
  • normalizeQuestion is the boundary between the API’s world and the app’s world — snake_case to camelCase, HTML decoded, answers shuffled.
  • fetchQuestions validates the response code with the ResponseCode enum before mapping results.