Module 9 · TypeScript · Deep Dive
The type system that catches bugs before they ship — and the reason every UAE job spec says "React + TypeScript", not just "React".
BasicIntermediateBuild
TypeScript is a superset of JavaScript: every valid .js file is already valid TypeScript. You add type annotations, the compiler (tsc) checks them, then erases them — the browser runs plain JavaScript. The types exist only at build time to protect you.
// JavaScript: this bug ships and crashes at runtime function greet(user) { return "Hi " + user.nmae; // typo — undefined, but no warning } // TypeScript: the compiler stops you before you ever run it function greet(user: { name: string }) { return "Hi " + user.nmae; // Error: Property 'nmae' does not exist }
Install it per-project and create a config:
# add TypeScript 5.x to your project npm install --save-dev typescript # generate a tsconfig.json npx tsc --init # type-check (and compile) on demand npx tsc
The single most important setting is strict mode. It turns on the checks that make TypeScript worth using — most importantly strictNullChecks, which forces you to handle null and undefined instead of letting them blow up later.
tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"strict": true, // turn EVERYTHING on — non-negotiable
"noUncheckedIndexedAccess": true,
"skipLibCheck": true
}
}
strict mode give you?" is a near-guaranteed screening question. The headline answer: strict null checks — string can no longer secretly be null, so you're forced to handle the missing case. Saying this signals you've shipped real TypeScript, not just toy examples.
.ts file is a .js file you've started annotating. You can migrate one file at a time; nothing forces you to type everything at once.
You rarely annotate everything. TypeScript infers types from values, and you should let it. Annotate when inference can't help — function parameters, empty containers, and public API boundaries.
// Inferred — DON'T write : string here, it's redundant let title = "Intro.pdf"; // inferred as string let pages = 12; // inferred as number let ready = true; // inferred as boolean // Explicit — needed when there's no value to infer from let userId: string; // will assign later function chunk(text: string) { /* ... */ } // params always need types
The primitives are string, number (no separate int/float), boolean, null, undefined. Note they're lowercase — the capitalised String/Number are wrapper objects you almost never want.
Arrays hold many of one type. Tuples are fixed-length arrays where each position has its own type:
// Array — any number of strings const tags: string[] = ["ai", "uae"]; const scores: Array<number> = [0.91, 0.74]; // same thing, generic form // Tuple — exactly two elements, in this order const embedding: [number, number] = [0.1, 0.9]; const result: [string, number] = ["intro.pdf", 0.87]; // [filename, similarity]JS bridge: in plain JS an array could hold anything. Here
string[] means the compiler rejects tags.push(42) — a whole class of bugs gone.
This is where TypeScript earns its keep. You describe the shape of an object once and reuse it everywhere. There are two ways: interface and type alias.
// interface — the classic way to describe an object shape interface Document { id: string; title: string; pages: number; uploadedAt: string; } // type alias — does the same here, plus much more elsewhere type Document = { id: string; title: string; pages: number; uploadedAt: string; };
When to use which? A practical rule that holds up in interviews:
interface for object shapes you might extend — domain models, component props, anything object-like. It supports extends and reads cleanly.type when you need unions, intersections, tuples, or to alias a primitive — things interface simply can't express.// interface composes with extends interface Timestamped { createdAt: string; } interface ChatMessage extends Timestamped { id: string; text: string; } // type can do things interface can't: type Role = "user" | "assistant"; // a union — interface can't type Stamped<T> = T & Timestamped; // an intersection
interface and type are interchangeable 95% of the time. Pick one and be consistent. The teams I've worked with default to interface for models and type for unions/utilities — that's the convention DocChat uses.
A union says "this value is one of these types". A literal type narrows a primitive to specific values — the safest way to model a fixed set of options.
// A union of literal strings — only these three are allowed type Status = "pending" | "ready" | "failed"; let s: Status = "ready"; // fine s = "done"; // Error: not assignable to Status // A union of types type Id = string | number;
When a value could be several types, you must narrow it before using type-specific operations. TypeScript follows your runtime checks and narrows automatically.
// typeof narrowing function format(id: string | number): string { if (typeof id === "string") { return id.toUpperCase(); // here id is narrowed to string } return id.toFixed(0); // here it must be number } // in narrowing — does this key exist? type Ok = { data: string }; type Err = { error: string }; function read(r: Ok | Err) { if ("data" in r) return r.data; // narrowed to Ok return r.error; // narrowed to Err }
The most powerful pattern is the discriminated union: every member shares a common literal field (the "tag"), and switching on it narrows perfectly. This is exactly how DocChat models an API response.
type AskResult = | { status: "ok"; answer: string; sources: string[] } | { status: "error"; message: string }; function render(r: AskResult) { switch (r.status) { case "ok": return r.answer; // .sources is available, .message is NOT case "error": return r.message; // .answer is gone here — the compiler tracks it } }
answer?: string) means "string or undefined" — it does not group related fields. A discriminated union ties fields together: when status is "error" there is no answer field at all. Reach for the union when fields come and go together.
Optional (?) and readonly modifiers refine object shapes:
interface Document { readonly id: string; // can't be reassigned after creation title: string; summary?: string; // optional: string | undefined }
Annotate the parameters and (optionally) the return type. Return types are usually inferred, but writing them on public functions documents intent and catches mistakes inside the body.
// params typed, return inferred as number function add(a: number, b: number) { return a + b; } // optional (?) and default params function chunk(text: string, size: number = 500, overlap?: number): string[] { // overlap is number | undefined return []; } // void — returns nothing meaningful function log(msg: string): void { console.log(msg); } // arrow functions, fully typed const similarity = (a: number[], b: number[]): number => { return 0; // real cosine math lives in the RAG module };JS bridge: the runtime behaviour is identical to your JS arrow functions. The only addition is the
: type annotations, which vanish at compile time.
Generics let a function or type work over many types while staying type-safe. The <T> is a placeholder filled in at the call site. This is how you write one reusable API wrapper for every response shape in DocChat.
// Without generics you'd lose the type — this keeps it function first<T>(items: T[]): T | undefined { return items[0]; } const a = first(["x", "y"]); // a: string | undefined const b = first([1, 2, 3]); // b: number | undefined
A constraint (extends) limits what T can be, so you can safely touch certain properties:
// T must have an id field of type string function byId<T extends { id: string }>(items: T[], id: string): T | undefined { return items.find((x) => x.id === id); }
TypeScript ships utility types — generics that transform existing types. These five come up constantly and in interviews:
interface Document { id: string; title: string; pages: number; } // Partial<T> — all fields optional (great for updates) type DocPatch = Partial<Document>; // { id?; title?; pages? } // Pick<T, K> — keep only some fields type DocCard = Pick<Document, "id" | "title">; // Omit<T, K> — everything except some fields type NewDocument = Omit<Document, "id">; // id assigned by the DB // Record<K, V> — an object map type StatusCount = Record<string, number>; // { [tag]: count } // Readonly<T> — freeze every field type FrozenDoc = Readonly<Document>;
Partial<Document>. "And the create payload, where the server assigns the id?" — Omit<Document, "id">. Knowing the utility types by name is a fast credibility signal.
Three special types decide how safe your boundaries are:
any — turns checking off for that value. It's an escape hatch and a foot-gun; every any is a hole in your safety net. Avoid it.unknown — "I don't know the type yet". Safe, because you must narrow it before use. This is the correct type for raw JSON and external input.never — "this can't happen". Returned by functions that always throw, and used to prove a switch is exhaustive.// unknown forces a check before use — any does not function parse(raw: unknown) { if (typeof raw === "string") { return raw.trim(); // allowed only after narrowing } throw new Error("expected string"); } // never proves you handled every case function assertNever(x: never): never { throw new Error("unhandled case"); }
Enums vs unions of literals. TypeScript has an enum keyword, but the modern community default is a union of string literals — it's simpler, erases cleanly to plain strings, and plays nicely with JSON from your API.
// Prefer this — a union of literals type Role = "user" | "assistant" | "system"; // Over this — an enum adds a runtime object and import friction enum RoleEnum { User, Assistant, System } // avoid for new code
Typing JSON / API responses. fetch().json() returns any by design — a hole you should close immediately. Type the call site so the rest of your code is safe:
interface Document { id: string; title: string; pages: number; } async function getDocuments(): Promise<Document[]> { const res = await fetch("/api/documents"); const data = (await res.json()) as Document[]; // assert the shape at the edge return data; }
id, title, and pages satisfies Document even if it was never labelled one. This is why types feel light — you're describing shapes, not building rigid class hierarchies.
types.ts — the shared type contract for DocChat. These types describe exactly what the FastAPI backend returns and what the Next.js frontend consumes. One source of truth means the two halves of your app physically cannot drift apart.
This brings together everything above: interfaces for the domain models, a discriminated-union-friendly generic ApiResponse<T>, a union of literals for roles, and request/response pairs for the ask endpoint.
types.ts
// ---- Domain models ---- interface Document { readonly id: string; title: string; pages: number; status: "pending" | "ready" | "failed"; // ingestion state uploadedAt: string; // ISO timestamp } type Role = "user" | "assistant"; interface ChatMessage { readonly id: string; role: Role; text: string; createdAt: string; } // ---- Generic API envelope (one wrapper for every endpoint) ---- type ApiResponse<T> = | { ok: true; data: T } | { ok: false; error: string }; // ---- The ask endpoint: request in, response out ---- interface AskRequest { documentId: string; question: string; topK?: number; // optional: how many chunks to retrieve } interface Source { documentId: string; snippet: string; score: number; // pgvector similarity } interface AskResponse { answer: string; sources: Source[]; // the chunks the LLM grounded its answer on }
Now the frontend's fetch helper uses the envelope, and narrowing on ok gives perfectly typed branches — no casting, no guessing:
api.ts
async function ask(req: AskRequest): Promise<ApiResponse<AskResponse>> { const res = await fetch("/api/ask", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(req), }); return (await res.json()) as ApiResponse<AskResponse>; } // Calling it — the union forces you to handle failure const result = await ask({ documentId: "d1", question: "What is the deadline?" }); if (result.ok) { console.log(result.data.answer); // .data is AskResponse here } else { console.error(result.error); // .data does NOT exist here }
That ApiResponse<T> wrapper is the single most reusable type you'll write. Define it once, use it for documents, chat history, uploads — every endpoint in DocChat.
Answer from memory — retrieval is what moves this from "I read it" to "I know it".
Which tsconfig setting forces you to handle null and undefined?
For a value that is "user" or "assistant", what should you reach for?
Which type is the safe choice for raw JSON you haven't checked?
You want every field of Document made optional for a patch. Which utility?
What lets one function work over many types while staying type-safe?