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:
- The file and line come first. Go there.
- The last line of the message is usually the actual mismatch:
Type 'undefined' is not assignable to type 'string'. - 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 anyandas unknown as— each one is a place the compiler stopped helping. Note why it’s there. - Grep for
ason anything that came fromres.json(),localStorage,FormDataor a query string. Those are the real boundaries. - Check that every
fetchof a shape you render goes through a runtime validator, not a cast. - Look for
isLoadingpluserrorplusdataas separate state. Ask whether the JSX handles all eight combinations, or quietly picks a winner. - Check
useState(null)anduseState([])calls for a missing type argument. - Check
childrenis typedReact.ReactNode, notReactElement. - Confirm the build actually runs
tsc(orastro check) in CI.vite buildalone transpiles TypeScript without type-checking it, so type errors can ship happily.
An agent writes: const survey = (await res.json()) as SurveyResponse. What guarantee does this give at runtime?
Sources
- Using TypeScript — React website Typing props, useState, useReducer and useContext, plus ReactNode versus ReactElement, CSSProperties and DOM event types such as ChangeEvent.
- Narrowing — TypeScript Handbook website Discriminated unions and how a shared literal property lets the compiler narrow a union in a switch.
- More on Functions — TypeScript Handbook website unknown as a safer any, and generic type parameters with extends constraints.
- Basic usage — Zod website parse, safeParse and its success/error result, and deriving a static type with z.infer.