Module 9 · TypeScript · Deep Dive
Types stop being homework and start paying you back — props that document themselves, events that can't be mistyped, fetch calls that fail loudly, and a Next 15 App Router that knows your params are a Promise.
BasicIntermediateBuild
$('#x').val() was always any; here you'll make the compiler hand you autocomplete and red squiggles instead.
A React component is just a function that returns JSX. Type its one argument — the props object — and you're done. Prefer a plain function with an explicit props type over the older React.FC:
type GreetingProps = {
name: string;
count?: number; // optional — the ? makes it string | undefined
};
function Greeting({ name, count = 0 }: GreetingProps) {
return <p>Hi {name}, you have {count} docs</p>;
}
For components that wrap other markup, type children as React.ReactNode — that one type covers strings, numbers, elements, arrays, and null:
type CardProps = {
title: string;
children: React.ReactNode;
};
function Card({ title, children }: CardProps) {
return (
<section>
<h3>{title}</h3>
{children}
</section>
);
}
React.FC used to implicitly add a children prop, which silently allowed children even when your component didn't want them. Modern React (18+/19) dropped that, but the convention stuck: declare props explicitly so children is opt-in and your component signature reads like documentation. Plain function components also type generics and default props more naturally.
options object and hoped the keys matched. A props type is that options contract — but the editor now autocompletes it and the build fails if you pass nmae.
React wraps native DOM events in synthetic events, and TypeScript ships generic types for each. The trick is to parameterise them with the element they fire on, so e.target.value is known to be a string:
function SearchBox() { function handleChange(e: React.ChangeEvent<HTMLInputElement>) { console.log(e.target.value); // typed as string } function handleSubmit(e: React.FormEvent<HTMLFormElement>) { e.preventDefault(); } function handleClick(e: React.MouseEvent<HTMLButtonElement>) { console.log(e.currentTarget.disabled); } return ( <form onSubmit={handleSubmit}> <input onChange={handleChange} /> <button onClick={handleClick}>Go</button> </form> ); }
If you write the handler inline, you usually don't need the annotation at all — React infers it from the JSX attribute. Annotate only when you pull the handler out into a named function. The common ones to memorise: ChangeEvent for inputs, FormEvent for forms, MouseEvent for clicks, KeyboardEvent for keys.
$('input').on('change', e => e.target.value) gave you any. React.ChangeEvent<HTMLInputElement> is the same event, but now .target.value autocompletes and a typo on .checked vs .value is caught.
Most of the time React's hooks infer their types for you. You step in with an explicit type parameter when the initial value doesn't tell the whole story.
// Inferred fine — count is number const [count, setCount] = useState(0); // Initial value is null, but it will hold a User — say so const [user, setUser] = useState<User | null>(null); // Empty array would infer never[] — pin the element type const [docs, setDocs] = useState<Doc[]>([]);
const inputRef = useRef<HTMLInputElement>(null); function focusIt() { inputRef.current?.focus(); // current is HTMLInputElement | null } // <input ref={inputRef} />
This is where types earn their keep: model the actions as a union and the reducer becomes exhaustively checked.
type State = { count: number };
type Action =
| { type: "inc" }
| { type: "add"; by: number };
function reducer(state: State, action: Action): State {
switch (action.type) {
case "inc":
return { count: state.count + 1 };
case "add":
return { count: state.count + action.by };
}
}
const [state, dispatch] = useReducer(reducer, { count: 0 });
dispatch({ type: "add", by: 3 }); // by is required only for "add"
The classic pitfall: a context defaulting to null forces every consumer to null-check. Wrap it in a custom hook that throws if used outside the provider, and consumers get a clean, non-null type:
auth-context.tsx
type AuthValue = {
user: User | null;
login: (email: string) => void;
};
const AuthContext = createContext<AuthValue | null>(null);
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const value: AuthValue = { user, login: (email) => setUser({ email }) };
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useAuth(): AuthValue {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error("useAuth must be used inside <AuthProvider>");
return ctx; // narrowed to AuthValue — no null for callers
}
if (!ctx) throw removes null from the type, everything after it is AuthValue. Callers write const { user } = useAuth() with zero null-checks. This pattern shows up in nearly every production React codebase — learn it once.
A custom hook is a function starting with use that calls other hooks. Type its return as a tuple or object so callers know what they get. The most useful pattern for any async UI is a discriminated union — one field (the discriminant) tells TypeScript which other fields exist.
type Result<T> =
| { status: "loading" }
| { status: "error"; message: string }
| { status: "success"; data: T };
function useDoc(id: string): Result<Doc> {
const [state, setState] = useState<Result<Doc>>({ status: "loading" });
useEffect(() => {
fetchDoc(id)
.then((data) => setState({ status: "success", data }))
.catch((e) => setState({ status: "error", message: String(e) }));
}, [id]);
return state;
}
Now the component cannot read data until it has proven the status is "success". The compiler forces you to handle every branch — no more "cannot read property of undefined" while loading:
function DocView({ id }: { id: string }) { const r = useDoc(id); if (r.status === "loading") return <Spinner />; if (r.status === "error") return <p>{r.message}</p>; return <h2>{r.data.title}</h2>; // r.data is safe here }jQuery bridge: remember juggling
isLoading, error, and data as three separate flags and accidentally rendering data mid-load? A discriminated union makes the impossible state un-representable.
fetch returns Promise<any> after .json() — a hole straight through your type safety. The backend could send anything; types alone trust it blindly. zod 3 closes the hole: you describe the shape once, validate at runtime, and derive the TypeScript type from the schema with z.infer.
lib/api.ts
import { z } from "zod"; // One source of truth: schema first… const DocSchema = z.object({ id: z.string(), title: z.string(), pages: z.number().int(), created_at: z.string(), }); // …then the type falls out of it for free export type Doc = z.infer<typeof DocSchema>; export async function getDoc(id: string): Promise<Doc> { const res = await fetch(`${API_URL}/docs/${id}`); if (!res.ok) throw new Error(`HTTP ${res.status}`); const json: unknown = await res.json(); return DocSchema.parse(json); // throws if FastAPI sent the wrong shape }
unknown, never any. unknown forces you to validate before use; any waves everything through. DocSchema.parse() is the gate that turns trusted-nothing unknown into a trusted Doc. Use .safeParse() when you'd rather return a result object than throw.
This is the single most valuable habit on the DocChat frontend: every response from your FastAPI backend passes through a zod schema, so a mismatched field is a clear error at the boundary instead of a mysterious undefined three components deep.
Next 15 with the App Router is server-first. Components are React Server Components by default; you opt into the browser with "use client". The big typing change in Next 15: route params and searchParams are now Promises — you await them.
app/docs/[id]/page.tsx
type PageProps = {
params: Promise<{ id: string }>;
searchParams: Promise<{ q?: string }>;
};
export default async function DocPage({ params, searchParams }: PageProps) {
const { id } = await params;
const { q } = await searchParams;
const doc = await getDoc(id); // async Server Component — await right in the body
return <h1>{doc.title}{q && ` — ${q}`}</h1>;
}
params was a plain object you read synchronously. In Next 15 it's Promise<...> and you must await it (or unwrap with React's use() in a Client Component). Code that destructures { id } = params without awaiting is the old pages-era pattern and will break.
import type { Metadata } from "next"; export async function generateMetadata( { params }: PageProps ): Promise<Metadata> { const { id } = await params; const doc = await getDoc(id); return { title: doc.title }; }
A Server Action is an async function marked "use server" that runs on the server but is callable from the client. Validate its input with zod and give it a typed return so the calling component knows what it gets back:
app/docs/actions.ts
"use server"; import { z } from "zod"; const Input = z.object({ title: z.string().min(1) }); type ActionResult = | { ok: true; id: string } | { ok: false; error: string }; export async function createDoc(formData: FormData): Promise<ActionResult> { const parsed = Input.safeParse({ title: formData.get("title") }); if (!parsed.success) return { ok: false, error: "Title required" }; const doc = await getDoc("new"); // (really: POST to FastAPI) return { ok: true, id: doc.id }; }
A Route Handler in app/api/.../route.ts is a typed function per HTTP verb. Use the Web-standard Request/Response, or Next's NextRequest/NextResponse when you want cookies, geo, or typed JSON helpers:
app/api/health/route.ts
import { NextRequest, NextResponse } from "next/server"; export async function GET(req: NextRequest) { const q = req.nextUrl.searchParams.get("q"); return NextResponse.json({ ok: true, q }); } export async function POST(req: Request) { const body: unknown = await req.json(); // validate body with a zod schema before trusting it return Response.json({ received: true }, { status: 201 }); }
process.env.X is typed string | undefined — every variable might be missing. Don't sprinkle ! non-null assertions everywhere; validate the whole environment once at startup with zod and export a typed, guaranteed-present object:
lib/env.ts
import { z } from "zod"; const EnvSchema = z.object({ NEXT_PUBLIC_API_URL: z.string().url(), DATABASE_URL: z.string().min(1), }); export const env = EnvSchema.parse(process.env); // env.NEXT_PUBLIC_API_URL is string (never undefined) and validated as a URLjQuery bridge: this is the front-end equivalent of failing fast — instead of a blank page when an env var is missing in production, the app refuses to boot with a precise message about which variable is wrong.
First, the typed API layer. The schema is the contract; the type is derived; the call validates:
lib/chat-api.ts
import { z } from "zod"; import { env } from "./env"; const AnswerSchema = z.object({ answer: z.string(), sources: z.array(z.object({ doc_id: z.string(), snippet: z.string() })), }); export type Answer = z.infer<typeof AnswerSchema>; export async function ask(question: string): Promise<Answer> { const res = await fetch(`${env.NEXT_PUBLIC_API_URL}/chat`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ question }), }); if (!res.ok) throw new Error(`Chat failed: ${res.status}`); const json: unknown = await res.json(); return AnswerSchema.parse(json); // pgvector RAG result, validated }
Now the Client Component. Notice the discriminated-union state, the typed ChangeEvent and FormEvent, and that nothing reads answer until status proves "success":
app/chat/ChatBox.tsx
"use client"; import { useState } from "react"; import { ask, type Answer } from "@/lib/chat-api"; type ChatState = | { status: "idle" } | { status: "loading" } | { status: "error"; message: string } | { status: "success"; answer: Answer }; export default function ChatBox() { const [q, setQ] = useState(""); const [state, setState] = useState<ChatState>({ status: "idle" }); async function onSubmit(e: React.FormEvent<HTMLFormElement>) { e.preventDefault(); setState({ status: "loading" }); try { const answer = await ask(q); setState({ status: "success", answer }); } catch (err) { setState({ status: "error", message: String(err) }); } } return ( <form onSubmit={onSubmit}> <input value={q} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setQ(e.target.value)} placeholder="Ask your documents…" /> <button disabled={state.status === "loading"}>Ask</button> {state.status === "loading" && <p>Thinking…</p>} {state.status === "error" && <p>{state.message}</p>} {state.status === "success" && ( <article> <p>{state.answer.answer}</p> <ul> {state.answer.sources.map((s) => ( <li key={s.doc_id}>{s.snippet}</li> ))} </ul> </article> )} </form> ); }
Every red squiggle you would have hit at runtime — a missing sources field, reading answer while loading, a typo on e.target.value — is now caught before the file even saves. That's the whole promise of TypeScript in React, made concrete.
Answer from memory — retrieval is what moves this from "I read it" to "I know it".
Which type covers anything renderable as a child?
How do you type an input's change handler?
In Next.js 15, a page's params is now…
How do you derive a TS type from a zod schema?
What makes loading/error/success states safe?
params rules, and zod.dev for schema-derived types.