Module 9 · TypeScript · Drills

Drills: TypeScript in React & Next.js

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.

How to use this page Each drill is a small task. Attempt it first in a real TSX file (or the TS Playground), make it type-check, then click “Show solution” to compare. If yours type-checks differently but correctly — great, that's fluency. Tick each box as you go; your progress is saved in this browser.

A · Warm-up reps Basic

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.

Show solution
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.

Show solution
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.

Show solution
const inputRef = useRef<HTMLInputElement>(null);

function focusInput() {
  inputRef.current?.focus();   // optional chaining handles null
}
// usage: <input ref={inputRef} />

B · Stretch Intermediate

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.

Show solution
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.

Show solution
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.

Show solution
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.

C · Build challenge Build

Mini-project Build a typed DocChat upload form: a Server Action that takes a 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.

Show solution
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.

D · Rapid recall Flashcards

Click a card to flip it. Say the answer out loud before you flip — that's the rep that builds storage strength.

Type for any renderable child?
React.ReactNode — strings, numbers, elements, arrays, null.
click to flip
Type an input's change event?
React.ChangeEvent<HTMLInputElement>e.target.value is string.
click to flip
Type a DOM ref for an input?
useRef<HTMLInputElement>(null); access via ref.current?
click to flip
Derive a TS type from a zod schema?
z.infer<typeof Schema>
click to flip
In Next 15, what is a page's params?
A Promise — you await params in an async component.
click to flip
Why prefer a discriminated union for UI state?
It makes impossible states unrepresentable; the compiler forces every branch.
click to flip

E · Self-check before moving on

Tick each only if you can do it without looking:

Next All ticked? Your React and Next.js code is now type-safe end to end. Next we prove it actually works: Module 10 — Testing: Python & FastAPI.