Modeling the Data: Interfaces and Enums
With the scaffold in place, start with data. types.ts is the first file you fill in — every other module imports from it. Writing types before implementation ensures that the shape of every value in the app is agreed upon before any function tries to use it.
What types.ts contains
Section titled “What types.ts contains”AceIt needs types for six things:
- The difficulty levels the API accepts
- The raw API response — the JSON the browser receives
- The normalized question the app works with internally
- The full API envelope including response code
- The high score stored in
localStorage - The quiz state — which screen is active and what data it carries
Type aliases
Section titled “Type aliases”export type Difficulty = 'easy' | 'medium' | 'hard';A literal union — not string. Passing 'expert' to any function that expects Difficulty is a compile error, not a silent API failure.
export type ScreenId = | 'start-screen' | 'loading-screen' | 'question-screen' | 'result-screen';Constrains showScreen to the four IDs that actually exist in the HTML. Typos are errors.
Interfaces — the API response shape
Section titled “Interfaces — the API response shape”RawQuestion mirrors exactly what the Open Trivia DB API returns — snake_case property names, HTML-encoded strings, an array of three incorrect answers:
export interface RawQuestion { type: string; difficulty: string; category: string; question: string; correct_answer: string; incorrect_answers: string[];}
export interface ApiResponse { response_code: number; results: RawQuestion[];}Declaring these interfaces means TypeScript can verify that fetchJson<ApiResponse> produces something with response_code and results — and that results[0].correct_answer is a string, not any.
Interfaces — the normalized app shape
Section titled “Interfaces — the normalized app shape”TriviaQuestion is what the app works with after normalization. It uses camelCase, readonly on every field (the data is fixed after normalization), and the Difficulty type alias for precision:
export interface TriviaQuestion { readonly question: string; readonly category: string; readonly difficulty: Difficulty; readonly correctAnswer: string; readonly allAnswers: string[];}The readonly fields communicate that once a question is normalized from the API response, none of its data should change. TypeScript enforces this across the entire codebase.
Interface — the high score
Section titled “Interface — the high score”export interface HighScore { score: number; total: number; date: string;}Serialized to localStorage as JSON. QuizEngine.loadHighScore parses and validates against this shape before returning it.
The discriminated union — quiz state
Section titled “The discriminated union — quiz state”QuizState models the four states the quiz can be in. Each member has a unique status literal — the discriminant — and carries only the data relevant to that state:
export type QuizState = | { status: 'idle' } | { status: 'loading' } | { status: 'active'; question: TriviaQuestion; index: number; total: number } | { status: 'done'; score: number; total: number };score only exists when status === 'done'. question only exists when status === 'active'. Accessing either in the wrong state is a compile error — invalid states are unrepresentable.
The enum — API response codes
Section titled “The enum — API response codes”The Open Trivia DB API returns a numeric response_code. The enum replaces magic numbers with readable names:
export enum ResponseCode { Success = 0, NoResults = 1, InvalidParam = 2, TokenNotFound = 3, TokenEmpty = 4,}data.response_code === ResponseCode.Success is readable. data.response_code === 0 is not.
The complete types.ts
Section titled “The complete types.ts”// ── Type aliases ───────────────────────────────────
export type Difficulty = 'easy' | 'medium' | 'hard';
export type ScreenId = | 'start-screen' | 'loading-screen' | 'question-screen' | 'result-screen';
export type QuizState = | { status: 'idle' } | { status: 'loading' } | { status: 'active'; question: TriviaQuestion; index: number; total: number } | { status: 'done'; score: number; total: number };
// ── API response shapes ────────────────────────────
export interface RawQuestion { type: string; difficulty: string; category: string; question: string; correct_answer: string; incorrect_answers: string[];}
export interface ApiResponse { response_code: number; results: RawQuestion[];}
// ── App data shapes ────────────────────────────────
export interface TriviaQuestion { readonly question: string; readonly category: string; readonly difficulty: Difficulty; readonly correctAnswer: string; readonly allAnswers: string[];}
export interface HighScore { score: number; total: number; date: string;}
// ── Enum ───────────────────────────────────────────
export enum ResponseCode { Success = 0, NoResults = 1, InvalidParam = 2, TokenNotFound = 3, TokenEmpty = 4,}Exercise
Section titled “Exercise”- Replace the
types.tsstub with the complete file above. - Run
npm run build— confirm it compiles with no errors. - For each exported type, identify which module introduced the concept it uses:
Difficulty→ Module ___RawQuestion→ Module ___readonly correctAnswer→ Module ___QuizState→ Module ___ResponseCode→ Module ___
- Try adding a fifth
status: 'paused'member toQuizState. Note that this compiles fine — it only becomes a problem in the switch statement (Lesson 06).
types.tsis the single source of truth — every other file imports from it.- Separate
RawQuestion(API shape) fromTriviaQuestion(app shape) — the boundary between them is the normalization step inapi.ts. readonlyonTriviaQuestionfields prevents accidental mutation after normalization.QuizStateas a discriminated union makes invalid states unrepresentable.ResponseCodeas an enum replaces the API’s magic numbers with readable names.- Write types first — agreed-upon shapes make every subsequent implementation step concrete.