Lesson 12 · Ecosystem
TypeScript with React
Typing props, events and hooks, generic components and discriminated unions.
Loading lesson…
Lesson 12 · Ecosystem
Typing props, events and hooks, generic components and discriminated unions.
Loading lesson…
You can't push a British plug into a European socket. You don't need to plug anything in or test whether it blows a fuse – you can tell from the shape alone. The shape of the socket is really a contract: "this goes here, and nothing else".
A props type is exactly that kind of socket shape. The component says: "I need a name (text), a role (either admin or user) and a function I'll call on click". Whoever uses it with a missing name or with the role "admn" finds out in the editor, before running anything – with a red squiggle, not an error in a customer's browser.
In the previous lesson we learned where type labels are stuck. Now we'll stick them on things typical for React: props, events, hooks, context and components that work with any data.
A user card that shows a name and a role and lets the parent know on click:
interface UserCardProps {id: numbername: stringage?: number // optionalrole: 'admin' | 'user' // only one of these two valuesonSelect: (id: number) => void // a function the card callschildren?: ReactNode // content between the tags}function UserCard({ id, name, age = 18, role, onSelect, children }: UserCardProps) {return (<div onClick={() => onSelect(id)}><strong>{name}</strong> ({age}) – {role}{children}</div>)}
What TypeScript watches out for:
<UserCard id={1} name="Anna" role="admin" onSelect={select} /> // ✅<UserCard id={1} role="admin" onSelect={select} />// ❌ Property 'name' is missing – a required prop is missing<UserCard id={1} name="Anna" role="admn" onSelect={select} />// ❌ Type '"admn"' is not assignable to type '"admin" | "user"' – a typo<UserCard id={1} name="Anna" role="user" onSelect={(id: string) => …} />// ❌ onSelect gets a number, not a string
'admin' | 'user') instead of a general string. The editor offers both options as you type and won't let a typo through.age is always a number.void = nothing useful).React offers several types of its own that come in handy in props:
| Type | When |
|---|---|
ReactNode | children and slots – text, a number, JSX, an array, null… anything renderable. |
ReactElement | When you need exactly one element (rare). |
ComponentProps<'button'> | All the props of a native element. |
ComponentProps<typeof MyComp> | The props of an existing component (even from a library that doesn’t export them). |
CSSProperties | An object for the style prop. |
You're writing your own Button with colour variants. But the user will want to pass disabled, type="submit", aria-label, title… too. Listing them all by hand makes no sense. So you take the shape of the native socket and add your own to it:
type ButtonProps = ComponentProps<'button'> & {variant?: 'primary' | 'ghost' | 'danger'}function Button({ variant = 'primary', className = '', ...rest }: ButtonProps) {return <button className={`btn btn-${variant} ${className}`} {...rest} />}
ComponentProps<'button'> = all the props a plain <button> has.& = "and at the same time". The result has the native props and our variant....rest collects everything we didn't take and passes it on to the <button>. Including onClick, disabled and ref.At the post office you fill in a form. At the top you tick whether you're sending a letter or a parcel – and depending on that you fill in different boxes. For a parcel, the weight and dimensions; for a letter, none of that. Anyone who ticks "letter" and fills in a weight has got something wrong.
A component that is either a link or a button with an action has the same problem. With ordinary optional props you can write nonsense:
// ❌ Both optional → you can pass both, or neitherinterface Props { href?: string; onClick?: () => void; children: ReactNode }// ✅ The "tick box" kind decides what else is requiredtype LinkOrActionProps =| { kind: 'link'; href: string; children: ReactNode }| { kind: 'action'; onClick: () => void; children: ReactNode }function LinkOrAction(props: LinkOrActionProps) {if (props.kind === 'link') {return <a href={props.href}>{props.children}</a> // TS knows: href is here}return <button onClick={props.onClick}>{props.children}</button> // and onClick here}
Inside the if TypeScript knows which variant you have and lets you touch only its properties. And from the outside you can't write kind="link" without href.
What you see: At the top, three buttons of our own Button component – with an icon, disabled with a native title, and with just an icon and an aria-label. At the bottom, two variants of LinkOrAction.
Try it:
title attribute appears – it got through via ...rest without us having to list it.kind="link" and onClick. If you uncommented it, TypeScript would report an error.The takeaway: You inherit native props with one line. A discriminated union guarantees the component always gets a sensible combination.
Loading the interactive part…
When you write a handler right in JSX, TypeScript knows which element and event it belongs to, and it infers the type of e by itself. When you pull the handler out into a separate variable, it loses that connection – and you have to write the type. It's the opaque box from the previous lesson.
// An inline handler: the type is inferred by itself – the simplest option<input onChange={(e) => setName(e.target.value)} />// A standalone handler: the event type + the element type in angle bracketsconst handleChange = (e: ChangeEvent<HTMLInputElement>) => setName(e.target.value)const handleSubmit = (e: FormEvent<HTMLFormElement>) => { e.preventDefault() }const handleKey = (e: KeyboardEvent<HTMLInputElement>) => { if (e.key === 'Enter') … }const handleClick = (e: MouseEvent<HTMLButtonElement>) => { e.currentTarget.disabled = true }// Or the type of the whole handler at onceconst onClick: MouseEventHandler<HTMLButtonElement> = (e) => { … }
With hooks the see-through box rule applies: when TypeScript sees the initial value and everything is clear from it, don't write the type. When the box is empty or "nothing yet", pass the type in angle brackets.
useState(0) // number – a see-through boxuseState<User | null>(null) // nothing now, a User later – you must say souseState<Product[]>([]) // an empty array – you must say so (otherwise never[])useRef<HTMLInputElement>(null) // a ref to an element on the pageuseRef<number | null>(null) // a drawer for a timer IDuseRef(0) // a drawer with a number – inferreduseReducer(reducer, initial) // the types are inferred from the reducercreateContext<AuthValue | null>(null) // + a custom hook with a null check// A custom hook returning a pairfunction useToggle() {…return [on, toggle] as const // readonly [boolean, () => void]}
With useReducer it's enough to type the reducer properly – the state and the actions as a discriminated union from the lesson on reducers. Everything else (the type of state, the allowed actions in dispatch) is inferred from it.
A vending machine doesn't know in advance what will be in it. Fill it with cans and it gives out cans; with biscuits, biscuits. And it won't give you a biscuit from a machine full of cans. A generic component works the same way: it doesn't know in advance what data it will work with, but once you give it the data, it "learns" its type and watches over it.
A typical example: a table that can show products, users, orders…
interface Column<T> {key: keyof T & string // only a real property of Theader: stringrender?: (row: T) => ReactNode // the row arrives correctly typed}interface TableProps<T> {rows: T[]columns: Column<T>[]}function Table<T>({ rows, columns }: TableProps<T>) { … }
<T> after the function name says: "this component has a type parameter, I don't know it yet".<Table rows={products} … />, TypeScript looks at rows and finds out that T is Product. The machine is filled.key in the columns must be a property of a product ('name', 'price'…) and the render function gets a Product.<Tablerows={products}columns={[{ key: 'name', header: 'Name' },{ key: 'price', header: 'Price', render: (p) => `$${p.price}` }, // p is a Product{ key: 'color', header: 'Colour' }, // ❌ 'color' isn't a property of Product]}/>
What you see: One Table component used twice: with products and with users. Columns with an arrow can be sorted.
Try it:
color column. A product has no colour – if you uncommented the line, TypeScript would underline it.The takeaway: The component is written once for any data. The data type is inferred from rows and watches over the columns and the render functions.
Loading the interactive part…
| Syntax | What for |
|---|---|
const sizes = ['S', 'M'] as const | A readonly pair of strings → typeof sizes[number] = "S" | "M". |
obj satisfies Record<Tone, string> | Checks the shape, but keeps the exact type of the value. |
Omit<ButtonProps, "type"> | Props without some keys. |
Partial<T>, Required<T>, Pick<T, K> | Derived types without copying. |
const x: never = action | An exhaustiveness check in a switch. |
z.infer<typeof schema> | A type from a Zod schema – validation and types from one source. |
The props type is written – the component just has to use it.
CHALLENGE: A badge with typed props
The props type (BadgeProps) is finished. The component doesn't use it yet.
1. Instead of (props: BadgeProps), destructure the props right in the parameter: function Badge({ label, tone = 'ok' }: BadgeProps)
2. Render <span className={tone}>{label}</span>
(the ok and bad classes are ready-made course styles – green and red)row, stack, card, list, btn, input or muted are the course's ready-made styles in src/styles.css (row = side by side, stack = stacked, card = bordered box, muted = grey text).Loading the interactive part…
The first challenge practises sections 1, 2 and 5: socket shapes for props, extending a native button and a generic Select. The second practises section 4 – a typed reducer and context in which you catch a typo in an action name before you save the file.
The UI looks the same – the change is only in the types. Only TypeScript can tell you've succeeded.
Medium and hard challenges are a bonus for signed-in readers. Signing up is free – just an e-mail, no password.
A discriminated union for actions + a typed context = you catch a typo in an action name before you save the file.
Medium and hard challenges are a bonus for signed-in readers. Signing up is free – just an e-mail, no password.
string.ComponentProps<'button'> & {…} for extending native elements, ReactNode for children.ChangeEvent<…> and the like.useState<T>() for null, unions and empty arrays; otherwise let TS infer.