🪴 GoDeep
Search
← Shipping Software

TypeScript in React

Read and write the types an agent puts on your React components — props, state, events, generics, discriminated unions for request state — and understand why a type on an API response is a promise, not a proof.

12 cards · 6 quiz questions · 10 min read

The React front end an agent built for you is .tsx, not .jsx, and it is dense with types: interface Props, useState<string | null>, React.ChangeEvent<HTMLInputElement>, the occasional as any. The build passes, so it must be right — except that a passing build means something narrower than it sounds. TypeScript is a very good tool for catching mistakes inside your own code and a completely powerless one at the edges where data arrives. Knowing where that line falls is most of what you need.

Types are a build-time argument, not a runtime check

TypeScript compiles to JavaScript by erasing the types. Nothing in the shipped bundle knows that survey was supposed to be a SurveyResponse. Every type you see is a claim the compiler checks against other claims in the same codebase — and then deletes.

That makes the compiler excellent at one class of bug: you renamed a prop and forgot a call site, you passed a number where a string was wanted, you read a field that doesn’t exist on that object. Those become build errors. It makes the compiler blind to a different class: the server changed its response shape.

Typing props and state

Props are an object, so you describe that object:

interface RespondentRowProps {
  id: string;
  label: string;
  completedAt?: string;        // `?` means it may be absent
  onSelect: (id: string) => void;
  children?: React.ReactNode;  // anything React can render
}

function RespondentRow({ id, label, onSelect }: RespondentRowProps) {
  return <button onClick={() => onSelect(id)}>{label}</button>;
}

State usually needs no annotation, because the initial value tells the compiler everything. The exception is when the initial value is narrower than the real set of values:

const [open, setOpen] = useState(false);              // inferred boolean — fine
const [sel, setSel] = useState(null);                 // inferred `null` — broken
const [sel2, setSel2] = useState<Respondent | null>(null);   // what you meant
const [status, setStatus] = useState<Status>("idle"); // rejects any other string

useState(null) is a common agent slip: the type is literally null, so the first real assignment fails to compile, and the usual “fix” is a cast.

ReactNode versus ReactElement

For children, use React.ReactNode. It is the union of everything React can render: elements, strings, numbers, arrays, null, undefined, booleans. React.ReactElement is a single JSX element object — so a component typed that way rejects plain text, rejects two children (they arrive as an array), and rejects a conditional that can be null. If a component’s children type ever forces callers to wrap things in a pointless div, this is why.

Events

Inline in the JSX, handler parameter types are inferred. You only annotate when the function is defined separately:

function onQueryChange(e: React.ChangeEvent<HTMLInputElement>) {
  setQuery(e.currentTarget.value);   // typed as string
}

function onSubmit(e: React.FormEvent<HTMLFormElement>) {
  e.preventDefault();
}

The pattern is the same throughout: the event kind, then the element in angle brackets. React.MouseEvent<HTMLButtonElement>, React.KeyboardEvent<HTMLTextAreaElement>, and React.SyntheticEvent as the common base.

A generic in a small reusable component

Generics let one prop’s type depend on another’s. That is the whole point — a type parameter that appears only once is doing nothing.

interface Column<T> {
  header: string;
  render: (row: T) => React.ReactNode;
}

interface DataTableProps<T extends { id: string }> {  // constraint: rows have ids
  rows: T[];
  columns: Column<T>[];
}

export function DataTable<T extends { id: string }>({ rows, columns }: DataTableProps<T>) {
  return (
    <tbody>
      {rows.map((row) => (
        <tr key={row.id}>
          {columns.map((c) => <td key={c.header}>{c.render(row)}</td>)}
        </tr>
      ))}
    </tbody>
  );
}

Now render: (r) => r.respondentId is checked against the actual row type at every call site, and r.respondantId is a build error instead of a blank cell. The extends { id: string } constraint also guarantees a stable key. The alternative an agent often reaches for — rows: any[] — compiles, and gives you nothing.

Discriminated unions for request state

Here is the shape agents produce most often for a data fetch:

// Before: eight combinations, four of which are nonsense.
const [data, setData] = useState<Survey | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);

Nothing stops isLoading being true while error and data are both set, so the JSX grows defensive conditionals whose ordering silently decides what the user sees — a spinner over stale numbers, or “no responses yet” during a reload.

A discriminated union is a union of object types sharing one literal property, which the compiler uses to narrow:

// After: the impossible states cannot be written down.
type Req =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "success"; data: Survey }
  | { status: "error"; message: string };

const [req, setReq] = useState<Req>({ status: "idle" });

Rendering it becomes a switch, and inside each branch the compiler knows exactly which fields exist:

switch (req.status) {
  case "loading":  return <Spinner />;
  case "error":    return <Alert text={req.message} />;   // `data` not available here
  case "success":  return <Chart survey={req.data} />;    // `data` guaranteed here
  default:         return null;
}

You cannot reach req.data without having checked the status. That is the compiler doing genuine work.

unknown versus any

any turns checking off for a value: every property access and call compiles, and mistakes surface at runtime, usually far from the cause. unknown also accepts any value, but lets you do nothing with it until you narrow it — a typeof check, an instanceof check, or a validator.

function handle(body: any) {
  return body.data.responses.length;   // compiles; may explode three levels deep
}

function handle2(body: unknown) {
  return body.data;   // compile error: 'body' is of type 'unknown'
}

The error in the second version is the tool working. unknown is the honest type for anything arriving from outside the program.

Which brings us to as. A type assertion overrides the compiler without checking anything, and as unknown as X is the version used when even as is refused. There are fine uses — as const for literal types, narrowing a DOM query. But as applied to network data, to a form value, or added immediately after a failing build, is almost always a silenced error rather than a fixed one.

Validate at the boundary

Typing an API response is a promise about the server. Make it true where the data lands:

import { z } from "zod";

const Survey = z.object({
  id: z.string(),
  waveName: z.string(),
  completes: z.number().int(),
  closedAt: z.string().nullable(),
});
type Survey = z.infer<typeof Survey>;   // one definition, no drift

export async function fetchSurvey(id: string): Promise<Survey> {
  const res = await fetch(`/api/surveys/${id}`);
  if (!res.ok) throw new Error(`Survey fetch failed: ${res.status}`);
  const parsed = Survey.safeParse(await res.json());
  if (!parsed.success) throw new Error(`Bad survey payload: ${parsed.error.message}`);
  return parsed.data;   // now the type is earned
}

safeParse returns a result you branch on rather than throwing, parse throws, and z.infer derives the static type from the schema so you never hand-maintain a matching interface. The payoff in a research tool is specific: when the FastAPI side renames completes to completed_count, you get a clear error naming the field, at the fetch, rather than a chart quietly drawing zeros.

Reading a scary tsc error

Long TypeScript errors are nested, and read best from the outside in:

  1. The file and line come first. Go there.
  2. The last line of the message is usually the actual mismatch: Type 'undefined' is not assignable to type 'string'.
  3. The middle — the chain of Types of property 'x' are incompatible — tells you where in a nested shape the mismatch sits.

A few codes worth recognising: 2339 means the property doesn’t exist on that type (usually a typo or a wrong type, not a missing feature); 2345 means an argument is the wrong type; 2532 and 18048 mean a value may be undefined, which is the compiler telling you about a real code path where data is missing, not a nuisance to cast away.

What to check in your own code

  • Grep for : any, as any and as unknown as — each one is a place the compiler stopped helping. Note why it’s there.
  • Grep for as on anything that came from res.json(), localStorage, FormData or a query string. Those are the real boundaries.
  • Check that every fetch of a shape you render goes through a runtime validator, not a cast.
  • Look for isLoading plus error plus data as separate state. Ask whether the JSX handles all eight combinations, or quietly picks a winner.
  • Check useState(null) and useState([]) calls for a missing type argument.
  • Check children is typed React.ReactNode, not ReactElement.
  • Confirm the build actually runs tsc (or astro check) in CI. vite build alone transpiles TypeScript without type-checking it, so type errors can ship happily.

Sources