Module 9 · TypeScript · Drills
Reading types is not knowing types. Type every one of these yourself — in your editor, watching the red squiggles appear and vanish — before you reveal the solution. Effortful recall is the point.
Drill 1 props
Write a type ButtonProps for a button with a required label: string, an optional disabled boolean, and children: React.ReactNode. Then write the component that uses it.
type ButtonProps = {
label: string;
disabled?: boolean;
children: React.ReactNode;
};
function Button({ label, disabled = false, children }: ButtonProps) {
return <button disabled={disabled} aria-label={label}>{children}</button>;
}
Drill 2 useState + event
Build a controlled text input: a useState string and an onChange handler typed as React.ChangeEvent<HTMLInputElement> that updates it.
function NameField() { const [name, setName] = useState(""); function onChange(e: React.ChangeEvent<HTMLInputElement>) { setName(e.target.value); // value is string } return <input value={name} onChange={onChange} />; }
Drill 3 useRef
Create a ref to an HTMLInputElement and a function that focuses it, safely handling the case where it's still null.
const inputRef = useRef<HTMLInputElement>(null); function focusInput() { inputRef.current?.focus(); // optional chaining handles null } // usage: <input ref={inputRef} />
Drill 4 discriminated union
Model a UI state for fetching a user as a discriminated union with loading, error (with a message), and success (with a User). Then write a render that the compiler forces you to handle exhaustively.
type User = { name: string };
type FetchState =
| { status: "loading" }
| { status: "error"; message: string }
| { status: "success"; user: User };
function render(state: FetchState) {
switch (state.status) {
case "loading": return "Loading…";
case "error": return state.message;
case "success": return state.user.name; // user only exists here
}
}
If you delete a case, TypeScript flags it (with noImplicitReturns/exhaustiveness). You literally cannot read state.user outside the success branch.
Drill 5 zod + z.infer
Write a zod schema for a chat message (role is either "user" or "assistant", plus a content: string), derive the TS type from it, and validate an unknown value.
import { z } from "zod"; const MessageSchema = z.object({ role: z.enum(["user", "assistant"]), content: z.string(), }); type Message = z.infer<typeof MessageSchema>; // { role: "user" | "assistant"; content: string } function parseMessage(raw: unknown): Message { return MessageSchema.parse(raw); // throws on bad shape }
Drill 6 Next 15 page
Type a Next.js 15 App Router page at app/docs/[id]/page.tsx whose params is an async Promise carrying { id: string }. Await it and render the id.
app/docs/[id]/page.tsx
type PageProps = {
params: Promise<{ id: string }>;
};
export default async function DocPage({ params }: PageProps) {
const { id } = await params; // Next 15: params is a Promise
return <h1>Doc {id}</h1>;
}
The async keyword on the component plus await params is the Next 15 shape. Destructuring params synchronously is the old pages-era habit.
FormData, validates the title and file with zod, and returns a typed result — paired with a small Client Component that calls it. This is the exact shape of the upload flow you'll ship in DocChat.
Build · typed upload form
Write the Server Action first (zod-validated, discriminated-union return), then the form that posts to it.
app/upload/actions.ts
"use server"; import { z } from "zod"; const UploadInput = z.object({ title: z.string().min(1, "Title required"), }); type UploadResult = | { ok: true; id: string } | { ok: false; error: string }; export async function uploadDoc(formData: FormData): Promise<UploadResult> { const parsed = UploadInput.safeParse({ title: formData.get("title"), }); if (!parsed.success) { return { ok: false, error: parsed.error.issues[0].message }; } const file = formData.get("file"); if (!(file instanceof File)) { return { ok: false, error: "File required" }; } // …POST title + file to FastAPI, get back an id… return { ok: true, id: "doc_123" }; }
app/upload/UploadForm.tsx
"use client"; import { useState } from "react"; import { uploadDoc } from "./actions"; export default function UploadForm() { const [msg, setMsg] = useState(""); async function onSubmit(e: React.FormEvent<HTMLFormElement>) { e.preventDefault(); const result = await uploadDoc(new FormData(e.currentTarget)); setMsg(result.ok ? `Uploaded ${result.id}` : result.error); } return ( <form onSubmit={onSubmit}> <input name="title" placeholder="Title" /> <input name="file" type="file" /> <button>Upload</button> {msg && <p>{msg}</p>} </form> ); }
Note how result.ok narrows the union: TypeScript only lets you read result.id when ok is true and result.error when it's false. The Server Action's input is validated by zod, so a missing title never reaches your backend.
Click a card to flip it. Say the answer out loud before you flip — that's the rep that builds storage strength.
React.ReactNode — strings, numbers, elements, arrays, null.React.ChangeEvent<HTMLInputElement> — e.target.value is string.useRef<HTMLInputElement>(null); access via ref.current?z.infer<typeof Schema>params?Promise — you await params in an async component.Tick each only if you can do it without looking:
children: React.ReactNodeuseState, useRef, useReducer)z.inferparams and a zod-validated Server Action