MENU
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.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} />
</>
);
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'.
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>;
src/props.tsx(10,28): error TS2820: Type '"sumbit"' is not assignable to type
'"button" | "reset" | "submit" | undefined'. Did you mean '"submit"'?
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>;
}
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' });
}
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'.
function useToggle(initial = false) {
const [on, setOn] = useState(initial);
return [on, () => setOn(v => !v)] as const; // readonly [boolean, () => void]
}
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.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></>;
}
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>;
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
}