Module 9 · TypeScript · Deep Dive

TypeScript in React & Next.js

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

Why this matters The DocChat frontend is React 19 on Next.js 15. Every component, every fetch to your FastAPI backend, every Server Action is a chance for a runtime crash — or, with types, a chance to catch the bug while you're still typing. This lesson is where TypeScript stops feeling like ceremony and starts feeling like a second pair of eyes. You came from jQuery, where $('#x').val() was always any; here you'll make the compiler hand you autocomplete and red squiggles instead.
In this lesson
  1. Typing component props
  2. Typing events
  3. Typing hooks & context
  4. Custom hooks & discriminated unions
  5. A typed fetch client with zod
  6. Next.js 15 App Router types
  7. Typed env vars
  8. Build: the DocChat chat component
  9. Check yourself

1 · Typing component props

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>
  );
}
Why not React.FC? (interviewers ask this) 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.
jQuery bridge: in jQuery you passed a loose 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.

2 · Typing events

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.

jQuery bridge: $('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.

3 · Typing hooks & context

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.

useState when the initial value is too narrow

// 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[]>([]);

useRef for DOM nodes

const inputRef = useRef<HTMLInputElement>(null);

function focusIt() {
  inputRef.current?.focus();   // current is HTMLInputElement | null
}
// <input ref={inputRef} />

useReducer typed state + actions

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"

useContext a typed context + provider

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
}
The narrowing trick Because the 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.

4 · Custom hooks & discriminated unions

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.

5 · A typed fetch client with zod

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, not any Type the parsed JSON as 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.

6 · Next.js 15 App Router types

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.

Page & layout props async params

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>;
}
Currency check — Next 15 In Next 14 and earlier, 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.

generateMetadata typed and async

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 };
}

Server Actions typed input + return

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 };
}

Route Handlers Request → Response

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 });
}

7 · Typed env vars

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 URL
jQuery 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.

8 · Build it: the DocChat chat component

Your tangible win A fully-typed DocChat chat component backed by a zod-validated API layer that talks to your FastAPI backend. Discriminated-union state, typed events, typed fetch — every piece from this lesson in one screen you'll actually ship.

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.

9 · Check yourself

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

Recall quiz

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?

Primary source ⭐ React Docs — Using TypeScript, the canonical guide to typing components, hooks, and events. Pair it with the Next.js App Router file-convention reference for the Next 15 async params rules, and zod.dev for schema-derived types.