Static Type Checking with TypeScript

Dynamic checking happens while the program runs: a prop arrives as a string where a number was expected, and something downstream throws, on a user's machine, weeks later. Static checking happens first, from the source text alone. React suits it unusually well: an application is a graph of function calls whose arguments are written as JSX attributes — exactly where a mistake is easy to make and hard to see. System How it checks Status in 2026 PropTypes At runtime, in development Inert since React 19 Flow Statically, Meta's own checker Niche; mostly inside Meta TypeScript Statically, at compile time The default for new projects Table 2.18: Type checking options for React and where each stands Volume 1 covers the language itself; what follows is the vocabulary React adds. npm create vite@latest my-app -- --template react-ts Listing 2.105: Scaffolding a React project with TypeScript The template gives you .tsx sources, a root tsconfig.json referencing one config for application code and one for vite.config.ts, and @types/react with @types/react-dom. Those two matter most: React ships no types of its own, so the @types/react version decides what your JSX means — keep its major in step with React's. Then change two things. The config leaves strict off, without which null is assignable to everything, so add "strict": true; and it pins TypeScript 6 rather than 7.0, the Go rewrite of the compiler. Warning: Vite never type-checks. Its dev server and its bundler both strip annotations with esbuild and ask no questions, so a file full of type errors still runs. That is why the template's build script runs tsc -b before vite build.

2.13.1 Why Types Help in React

A component's props are its public interface, and JSX gives no help in honoring it. Nothing in <PriceTag amount={1999} /> says whether the prop is called amount, whether it wants dollars or cents, or whether it is required. In JavaScript you find out by reading the component — and find out you read it wrong when the page renders $NaN.

interface PriceTagProps {

amountCents: number;

currency: 'USD' | 'EUR';

}

export function PriceTag({ amountCents, currency }: PriceTagProps) {

return <span>{currency === 'USD' ? '$' : '€'}{(amountCents / 100).toFixed(2)}</span>;

}

export const cart = (

<>

<PriceTag amountCents="1999" currency="USD" />

<PriceTag amountCents={1999} currency="GBP" />

<PriceTag amountCents={1999} />

</>

);

Listing 2.106: A component and three calls that get it wrong

src/why.tsx(11,15): error TS2322: Type 'string' is not assignable to type 'number'.

src/why.tsx(12,34): error TS2322: Type '"GBP"' is not assignable to type '"EUR" | "USD"'.

src/why.tsx(13,6): error TS2741: Property 'currency' is missing in type

'{ amountCents: number; }' but required in type 'PriceTagProps'.

Output of Listing 2.106 Three classes of bug, three errors, none needing the app to run: a value of the wrong type (the classic "1999" / 100), a value outside the allowed set, and a missing required prop. Misspell the prop name and you get a fourth. The second repays a look — 'USD' | 'EUR' is a union of literal types, so the compiler knows which strings are legal and your editor offers both. Rename amountCents and it lists every caller.Types are erased before the code runs, so they describe your source and nothing else; two boundaries lie outside their reach.Figure 2.47: Types cover the component tree; the network boundary must be checked at runtimeawait res.json() is typed any, so const user: User = await res.json() is a claim, not a check: a server that renames a field breaks the app as it would without TypeScript. Validate at the boundary with a schema library such as Zod (github.com/colinhacks/zod (https://github.com/colinhacks/zod)): UserSchema.parse(await res.json()) throws on bad data and returns a value typed from the schema — one declaration serving as both.

2.13.2 Typing Props and Children

Props are one object, so type them with one object type — interface or type alias; the choice barely matters. Annotate the parameter and inference does the rest. Two React names cover most cases: ReactNode is anything React can render (elements, strings, numbers, arrays, null, undefined, false) and is what children should almost always be; ComponentProps<'button'> is a built-in element's full prop type, so a wrapper inherits every native attribute — children included.

import type { ComponentProps, ReactNode } from 'react';

type ButtonProps = ComponentProps<'button'> &

{ variant?: 'primary' | 'ghost'; icon?: ReactNode };

export function Button({ variant = 'primary', icon, children, ...rest }: ButtonProps) {

return <button className={`btn btn-${variant}`} {...rest}>{icon}{children}</button>;

}

export const ok = <Button type="submit" onClick={() => {}} variant="ghost">Save</Button>;

export const bad = <Button type="sumbit">Save</Button>;

Listing 2.107: A button wrapper that accepts every native button prop

src/props.tsx(10,28): error TS2820: Type '"sumbit"' is not assignable to type

'"button" | "reset" | "submit" | undefined'. Did you mean '"submit"'?

Output of Listing 2.107 With no native element to borrow from, add children: ReactNode yourself or wrap the props type in PropsWithChildren<P>; for a prop holding another component's props, use ComponentProps<typeof Button>.When one prop's presence depends on another's value, model the props as a union of object types rather than making everything optional. The compiler narrows inside the component and rejects illegal combinations.

type AlertProps =

| { kind: 'error'; message: string; retry: () => void }

| { kind: 'info'; message: string };

export function Alert(props: AlertProps) {

return <div role="alert">{props.message}

{props.kind === 'error' && <button onClick={props.retry}>Retry</button>}</div>;

}

Listing 2.108: Discriminated union props <Alert kind="info" message="Saved." retry={...} /> is now TS2322, Property 'retry' does not exist on type 'IntrinsicAttributes & { kind: "info"; message: string; }', while props.retry inside the guard type-checks because the kind test narrowed props. Destructuring { kind, retry } in the parameter list loses that narrowing. Warning: Do not reach for React.FC<Props>. It was popular for declaring children implicitly, but @types/react removed that in version 18; it now only pins the return type and blocks generics.

2.13.3 Typing Hooks and State

useState is generic, and most of the time you should let it infer: useState('') gives you a string and a setter for it. Supply the type argument only when the initial value is too narrow for the states that follow — an empty array, or a null placeholder for something that arrives later.

import { useState } from 'react';

interface Todo { id: number; text: string; done: boolean }

export function useTodos() {

const [query, setQuery] = useState(''); // inferred: string

const [todos, setTodos] = useState<Todo[]>([]); // [] alone infers never[]

const [editing, setEditing] = useState<Todo | null>(null);

return { query, setQuery, todos, setTodos, editing, setEditing };

}

export function useBroken() {

const [todos, setTodos] = useState([]);

const [user, setUser] = useState(null);

setTodos([{ id: 1, text: 'Ship', done: false }]);

setUser({ name: 'Ada' });

}

Listing 2.109: Where useState needs help and where it does not

src/hooks.ts(13,13): error TS2322: Type '{ id: number; text: string; done: false; }' is not

assignable to type 'never'.

src/hooks.ts(14,13): error TS2353: Object literal may only specify known properties, and 'name'

does not exist in type '(prevState: null) => null'.

Output of Listing 2.109 Both messages are baffling the first time, and both come from widening. [] widens to never[], an array that can hold nothing. null widens to the type null, leaving the setter's updater-function overload as the only one that could still match — hence a complaint about (prevState: null) => null rather than about the object you passed.The rest of the Hooks API infers cleanly: useMemo from its callback's return value, useCallback from the function you pass, useEffect from nothing. useEffect does enforce one rule — its callback must return void or a cleanup function, so useEffect(() => fetchData(), []) fails with Type 'Promise<number>' is not assignable to 'void | Destructor'. The fix is a braced body.An array returned from a custom Hook infers as an array of the union of its elements, which is never useful. as const makes it a readonly tuple, so destructuring gives each name its own type.

function useToggle(initial = false) {

const [on, setOn] = useState(initial);

return [on, () => setOn(v => !v)] as const; // readonly [boolean, () => void]

}

Listing 2.110: as const turns an array return into a tuple Without it, useToggle() returns (boolean | (() => void))[] and the error lands far from its cause, at the JSX writing onClick={toggleOpen}. Prefer an object return unless callers should rename both values freely.

2.13.4 Typing Events and Refs

React's synthetic events (Section 2.3.9) have their own generic types, parameterized by the element the handler is attached to. Write the handler inline and you never meet them — onChange={e => ...} infers e from the <input> it sits on. Pull it into a named function and inference is gone, leaving error TS7006: Parameter 'e' implicitly has an 'any' type. Annotate the parameter or, better, the whole function with the matching handler type. Handler prop Event type Handler alias onChange, onInput ChangeEvent<T> ChangeEventHandler<T> onSubmit, onReset FormEvent<T> FormEventHandler<T> onClick, onMouseOver MouseEvent<T> MouseEventHandler<T> onKeyDown, onKeyUp KeyboardEvent<T> KeyboardEventHandler<T> Table 2.19: Common React event types; FocusEvent<T> and ClipboardEvent<T> follow the same pattern

import { useRef, useState } from 'react';

import type { ChangeEventHandler, FormEvent, Ref } from 'react';

function SearchBox({ ref }: { ref?: Ref<HTMLInputElement> }) {

const [term, setTerm] = useState('');

const onChange: ChangeEventHandler<HTMLInputElement> = e => setTerm(e.target.value);

const onSubmit = (e: FormEvent<HTMLFormElement>) => {

e.preventDefault();

console.log(new FormData(e.currentTarget).get('q'));

};

return <form onSubmit={onSubmit}>

<input name="q" ref={ref} value={term} onChange={onChange} />

<button onClick={e => e.currentTarget.blur()}>Go</button>

</form>;

}

export function Page() {

const input = useRef<HTMLInputElement>(null);

const timer = useRef<number | null>(null);

return <><SearchBox ref={input} />

<button onClick={() => input.current?.focus()}>Focus</button></>;

}

Listing 2.111: Typed handlers, a typed ref prop, and a typed DOM ref The element parameter earns its keep. e.currentTarget is typed as the element you named, so currentTarget.blur() on a MouseEvent<HTMLButtonElement> is valid while currentTarget.value is not, and new FormData(e.currentTarget) compiles only because the form event named HTMLFormElement. e.target is whichever element fired, typed loosely for most events; ChangeEvent<T> is the exception that types target as T, so e.target.value above is a string.Since React 19, ref is an ordinary prop, typed Ref<T> — no forwardRef, no ForwardedRef. On the receiving side useRef<HTMLInputElement>(null) produces a RefObject<HTMLInputElement | null>: the null belongs to the type because the ref holds nothing until React attaches the node after the first commit, and strict makes you acknowledge it (error TS18047: 'input.current' is possibly 'null'). The optional chain is not busywork: the ref is null on the first render and after unmount. For a mutable box, declare the union yourself, as timer does.

2.13.5 Generic Components

A component that renders whatever you give it need not know what that is. Typing items as unknown[] throws the caller's knowledge away; a generic keeps it. Declare the type parameter on the function and let each call site infer it.

import type { ReactNode } from 'react';

interface ListProps<T> {

items: readonly T[];

keyOf: (item: T) => string;

children: (item: T) => ReactNode;

}

export function List<T>({ items, keyOf, children }: ListProps<T>) {

return <ul>{items.map(i => <li key={keyOf(i)}>{children(i)}</li>)}</ul>;

}

interface User { id: string; name: string; age: number }

const users: User[] = [{ id: 'u1', name: 'Ada', age: 36 }];

export const good = <List items={users} keyOf={u => u.id}>{u => <b>{u.name}</b>}</List>;

export const bad = <List items={users} keyOf={u => u.id}>{u => <b>{u.nmae}</b>}</List>;

Listing 2.112: A list component that stays typed for every element type src/gen.tsx(14,70): error TS2339: Property 'nmae' does not exist on type 'User'. Output of Listing 2.112 T is solved once, from items, and flows into both callbacks: keyOf and the render function receive a User with no annotation anywhere. The same List serves Product[] or readonly string[].Three refinements matter. Constrain the parameter when the component relies on structure: function List<T extends { id: string }>(...) lets you drop keyOf and write key={item.id}. Tie a second parameter to the first with keyof when a prop names a field — with field: K where K extends keyof T, <Column of={users} field="naem" /> fails with Type '"naem"' is not assignable to type 'keyof User'. And declare generic components with function: in .tsx, const Id = <T>(x: T) => x is parsed as JSX and fails with JSX element 'T' has no corresponding closing tag.

2.13.6 Typing Context and Reducers

Context and reducers are where types pay off most: both hide the connection between producer and consumer. A reducer's actions are a feature's internal API, and a discriminated union turns that API into something the compiler enforces at every dispatch.

import { createContext, useContext, useReducer } from 'react';

import type { Dispatch, ReactNode } from 'react';

interface State { count: number; step: number }

type Action = { type: 'inc' } | { type: 'setStep'; step: number } | { type: 'reset' };

function reducer(state: State, action: Action): State {

switch (action.type) {

case 'inc': return { ...state, count: state.count + state.step };

case 'setStep': return { ...state, step: action.step };

default:

const unhandled: never = action; // exhaustiveness check

throw new Error(`Unhandled ${JSON.stringify(unhandled)}`);

}

}

const CounterContext = createContext<readonly [State, Dispatch<Action>] | null>(null);

export function CounterProvider({ children }: { children: ReactNode }) {

const api = useReducer(reducer, { count: 0, step: 1 });

return <CounterContext value={api}>{children}</CounterContext>;

}

export function useCounter() {

const api = useContext(CounterContext);

if (!api) throw new Error('useCounter needs <CounterProvider>');

return api; // narrowed to the tuple, never null

}

Listing 2.113: A typed reducer behind a typed context src/ctx.tsx(10,13): error TS2322: Type '{ type: "reset"; }' is not assignable to type 'never'. Output of Listing 2.113 That one error is the exhaustiveness check working. Inside default, TypeScript has narrowed action past every handled case; when the union is fully covered, what remains is never and the assignment is legal. Add a member to Action and forget to handle it — as 'reset' is here — and it fails, naming what you missed. The union protects callers too: dispatch({ type: 'setStep' }) is rejected with TS2345.The context needs the | null. createContext demands a default value and there is rarely an honest one, so passing null and narrowing it in a custom access Hook is the standard pattern: useCounter throws outside the provider and is non-nullable everywhere else, so no consumer writes api?.[0]. Two React 19 conventions appear here — <CounterContext value={...}> renders the context object directly, .Provider no longer being required, and useReducer infers State and Action from the reducer. For larger stores, Redux Toolkit's createSlice generates the action union (Section 2.18).

2.13.7 PropTypes and Flow as Legacy

You will meet both in older code; neither is a choice for new work.PropTypes checked props at runtime, in development builds only, warning in the console when a value had the wrong type or a required prop was missing. Declarations hung off the component as a static property — Greeting.propTypes = { name: PropTypes.string, children: PropTypes.element.isRequired }. React 19 dropped support: propTypes on a function component is ignored, so such code runs but checks nothing. The prop-types package remains on npm at 15.8.1, last published in 2023, for older React versions. PropTypes TypeScript PropTypes.string name?: string PropTypes.string.isRequired name: string PropTypes.oneOf(['a', 'b']) kind: 'a' | 'b' PropTypes.node / .element ReactNode / ReactElement PropTypes.shape({...}) an interface Table 2.20: Reading PropTypes declarations as TypeScript types The two differ in kind. PropTypes validated values that arrived, one render at a time, in the browser; TypeScript validates call sites, all of them, before the code ships — which is why a component nobody rendered during testing went unchecked.Flow is Meta's own static checker, built alongside React and still released regularly — version 0.331 appeared in September 2026 — but its use outside Meta has collapsed. Its syntax is close enough to TypeScript's (type Props = { name: string }) that converting a file is usually mechanical. Its distinguishing feature is aggressive inference: a file marked // @flow is checked even with no annotations, so square("2") against function square(n) { return n * n; } is an error. flow-remove-types strips the annotations when you migrate away.