Module 9 · TypeScript · Deep Dive

TypeScript Foundations

The type system that catches bugs before they ship — and the reason every UAE job spec says "React + TypeScript", not just "React".

BasicIntermediateBuild

Why this matters Open any Dubai or Abu Dhabi front-end listing in 2026 and you'll read the same line: "React + TypeScript". Plain JavaScript roles barely exist anymore. TypeScript is JavaScript with a contract: you describe the shape of your data once, and the compiler catches the typos, the missing fields, and the "undefined is not a function" crashes before they reach a browser. For DocChat it's the glue — the same type definitions describe what FastAPI sends and what Next.js expects, so the front and back end can't silently drift apart. This lesson rewires your jQuery-era instincts into typed, compiler-checked code.
In this lesson
  1. Why TypeScript & setup
  2. Inference, primitives, arrays & tuples
  3. Object types: interface vs type
  4. Unions, literals & narrowing
  5. Function typing
  6. Generics & utility types
  7. any vs unknown vs never
  8. Build: DocChat's type contract
  9. Check yourself

1 · Why TypeScript & setup

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
  }
}
Interview hook "What does strict mode give you?" is a near-guaranteed screening question. The headline answer: strict null checksstring 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.
JS bridge: a .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.

2 · Inference, primitives, arrays & tuples

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.

3 · Object types: interface vs type

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 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
The honest answer For describing an object's shape, 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.

4 · Unions, literals & narrowing

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
  }
}
The trap with optional vs union Marking a field optional (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
}

5 · Function typing

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.

6 · Generics & utility types

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>;
Interview hook "How would you type the body of a PATCH endpoint?" — answer 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.

7 · any vs unknown vs never, enums, JSON

Three special types decide how safe your boundaries are:

// 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;
}
Structural typing — the mental model shift TypeScript is structurally typed: a value fits a type if it has the right shape, regardless of its declared name ("duck typing, checked"). An object with 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.

8 · Build it

Your tangible win Write 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.

9 · Check yourself

Answer from memory — retrieval is what moves this from "I read it" to "I know it".

Recall quiz

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?

Primary source ⭐ The Official TypeScript Handbook — Everyday Types. The canonical, authoritative reference for everything above. For the utility types, see Handbook — Utility Types.