#Typing props and children
A component is a function, and its props are that function's one parameter. So typing props is just typing an object parameter: you write a type for the object and annotate the destructured parameter with it. Everything else in this chapter builds on that one idea.
The mental model that makes TypeScript + JSX click: every JSX element is a function call that TypeScript checks. <Card title="Plan" variant="info">Body</Card> is checked exactly as if you had written Card({ title: "Plan", variant: "info", children: "Body" }). Missing a required prop is a missing property; a wrong value is a type mismatch; the text between the tags is just a prop called children. Once you see JSX that way, the error messages read naturally.
From my notes: "Little bit on TypeScript & React" listed typing state, function arguments, generics in components, default props and a typed HOC. The notes had headings only for most of these (the examples were screenshots that didn't come through), so the examples in this chapter are new; the HOC example is mine and is covered in Typing a higher-order component.
import type { ReactNode } from "react";
type CardProps = {
title: string;
subtitle?: string; // optional: string | undefined inside the component
variant: "info" | "warning"; // a union of literal types
children: ReactNode; // anything React can render
};
export function Card({ title, subtitle, variant, children }: CardProps) {
return (
<section className={`card card-${variant}`}>
<h2>{title}</h2>
{subtitle && <p>{subtitle}</p>}
{children}
</section>
);
}CardPropsis an ordinary object type. React adds nothing special — the same type could describe the argument of any function.subtitle?: stringmakes the prop optional. Inside the component its type isstring | undefined, which is why the JSX guards it withsubtitle && …before rendering the<p>.variant: "info" | "warning"is a union of string literals. Callers can only pass one of those two exact strings, and editors autocomplete them — a much tighter contract thanstring.children: ReactNodedeclares that the component accepts children.ReactNodeis the widest "renderable" type: elements, strings, numbers,null,undefined, booleans, arrays of those, and (since React 19 types) promises of them.- The destructured parameter
{ title, subtitle, variant, children }: CardPropsis where the type is applied. Each variable gets its type fromCardProps; nothing has to be annotated twice.
Now the call sites. Each element is checked like a function call against CardProps:
import { Card } from "./Card";
export const ok = <Card title="Plan" variant="info">Body</Card>;
export const badVariant = <Card title="Plan" variant="danger">Body</Card>; // ❌ Type '"danger"' is not assignable to type '"info" | "warning"'.
export const noTitle = <Card variant="info">Body</Card>; // ❌ Property 'title' is missing in type '{ variant: "info"; children: string; }' but required in type 'CardProps'.
export const noChildren = <Card title="Plan" variant="warning" />; // ❌ Property 'children' is missing in type '{ title: string; variant: "warning"; }' but required in type 'CardProps'.okpasses every required prop with valid values, and the textBodybecomeschildren: "Body". A string is aReactNode, so it compiles.badVariantfails with TS2322 because"danger"is not one of the literals in the union. The error points at thevariantattribute, exactly like a wrong argument in a function call.noTitlefails with TS2741. Notice how the compiler describes the props it did receive —{ variant: "info"; children: string; }— which shows that the text between the tags really is achildrenproperty.noChildrenfails becausechildrenis required inCardProps. If you want children to be optional, writechildren?: ReactNode.- This is the whole point of typing props: the component's contract is checked at every call site, so renaming or tightening a prop shows you every place that needs to change.
React.FC and implicit children
Many codebases type components as const Card: React.FC<CardProps> = (props) => …. That still works, but one thing changed in the React 18 types (2022): FC no longer adds children for you. Before that, every FC silently accepted children whether it rendered them or not.
import type { FC, PropsWithChildren } from "react";
const Title: FC<{ text: string }> = ({ text }) => <h1>{text}</h1>;
export const a = <Title text="Hi">extra</Title>; // ❌ Type '{ text: string; children: string; }' is not assignable to type 'IntrinsicAttributes & { text: string; }'.
const Box: FC<PropsWithChildren<{ padded: boolean }>> = ({ padded, children }) => (
<div style={{ padding: padded ? 16 : 0 }}>{children}</div>
);
export const b = <Box padded>inside</Box>;Titleis anFC<{ text: string }>. With@types/react18 and later,FC<P>means exactlyP— no hiddenchildren.- Passing
extrabetween the tags therefore fails with TS2322, and the follow-up line of the message says "Property 'children' does not exist on type 'IntrinsicAttributes & { text: string; }'".IntrinsicAttributesis the set of props React allows on everything (justkey). PropsWithChildren<P>is a helper that addschildren?: ReactNodetoP. It's handy when children are optional; when they're required, writechildren: ReactNodeyourself.- Under React 17 types,
awould have compiled — a common source of confusion when you read older code or tutorials.
Extending native element props
A reusable Button should accept everything a real <button> does — type, disabled, aria-*, onClick — plus your own props. Don't list them by hand; take them from React's types:
import type { ComponentProps } from "react";
type ButtonProps = ComponentProps<"button"> & {
variant?: "primary" | "secondary";
};
export function Button({ variant = "primary", className, ...rest }: ButtonProps) {
return <button className={`btn btn-${variant} ${className ?? ""}`} {...rest} />;
}
export const save = <Button type="submit" disabled aria-label="Save" onClick={(e) => e.currentTarget.blur()} />;
export const typo = <Button colour="red" />; // ❌ Property 'colour' does not exist on type '…'. Did you mean 'color'?ComponentProps<"button">is the full props type of the intrinsic<button>element: every HTML attribute, every event handler typed forHTMLButtonElement, and — in React 19 —ref, because ref is now an ordinary prop.- The
&intersection adds our ownvariant. The default"primary"is applied in the destructuring (see Default props in TypeScript). ...restcollects everything else (type, disabled, aria-label, onClick, ref…) and spreads it onto the real<button>, so the component is a transparent wrapper.- In
save, the inlineonClickparametereneeds no annotation: it's inferred asMouseEvent<HTMLButtonElement>from the prop type, soe.currentTarget.blur()type-checks. typofails with TS2322, and the follow-up line is "Property 'colour' does not exist on type 'IntrinsicAttributes & ClassAttributes<HTMLButtonElement> & ButtonHTMLAttributes<HTMLButtonElement> & { ...; }'. Did you mean 'color'?" — the same typo protection (and suggestion) you get on a native element.ClassAttributesis where therefprop comes from.
ReactNode, ReactElement and JSX.Element
| Type | What it allows | Use it for |
|---|---|---|
ReactNode | anything renderable: elements, strings, numbers, null, undefined, booleans, arrays, promises | children and "slot" props like header, footer |
ReactElement | only an element object (the result of JSX or createElement) | props that must be one element, e.g. for cloneElement |
React.JSX.Element | the type of a JSX expression (ReactElement<any, any>) | rarely needed; inferred return types cover it |
Sometimes props are only valid in combinations: a link needs href, a button needs onClick, and passing both makes no sense. A discriminated union of prop types expresses that, and TypeScript narrows on the discriminant inside the component:
type LinkProps = { kind: "link"; href: string; label: string };
type ButtonActionProps = { kind: "button"; onClick: () => void; label: string };
type ActionProps = LinkProps | ButtonActionProps;
export function Action(props: ActionProps) {
if (props.kind === "link") {
return <a href={props.href}>{props.label}</a>; // props is LinkProps here
}
return <button onClick={props.onClick}>{props.label}</button>; // props is ButtonActionProps here
}
export const link = <Action kind="link" href="/docs" label="Docs" />;
export const mixed = <Action kind="link" onClick={() => {}} label="Docs" />; // ❌ Type '{ kind: "link"; onClick: () => void; label: string; }' is not assignable to type 'IntrinsicAttributes & ActionProps'.ActionPropsis a union of two object types that share a literal property,kind. That shared literal is the discriminant.- Inside
Action,props.kind === "link"narrowspropstoLinkProps, soprops.hrefis available; after theifreturns, onlyButtonActionPropsis left, soprops.onClickis available. - Note that the parameter is not destructured. Destructuring
{ kind, href }in the parameter list fails, becausehrefdoesn't exist on every member of the union. Keeppropswhole and narrow it. mixedsayskind="link"but passesonClickinstead ofhref. Becausekindis"link", the compiler checks it againstLinkPropsand reports TS2322 with "Property 'onClick' does not exist on type 'IntrinsicAttributes & LinkProps'." — the type makes the invalid combination impossible to write.