Topic 12.2
Typing Props and Children
In one line
Describe a component's props with a type or interface: optional props with ?, unions of string literals for variants, ReactNode for anything renderable, and ComponentProps<"button"> to accept every native prop of an HTML element.
Think of it like this
An order form at a sandwich shop. It lists what you must fill in (bread), what you may fill in (extra cheese), and for some boxes the only allowed answers (size: small, medium or large). Nobody can order "size: gigantic". A props type is that form for a component: required props, optional props, and the exact choices allowed.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- Type alias
- A name for a type:
type Size = "sm" | "lg". - Interface
- A name for an object shape:
interface Props { title: string }. It canextendother interfaces. - Optional prop
- A prop marked with
?. It may be left out, so its type includesundefined. - Union type
- A type that allows one of several options, written with
|:"primary" | "danger". - String literal type
- A type with exactly one allowed string, like
"primary". - Intersection
- Two object types joined with
&: the result has the properties of both. - ReactNode
- React's type for anything it can render: elements, text, numbers, null, arrays and more.
- PropsWithChildren
- A helper type that adds
children?: ReactNodeto your props. - ComponentProps
- A helper type that reads the props of an HTML tag (
"button") or of a component (typeof Card). - Omit
- A built-in TypeScript helper that removes keys from an object type:
Omit<Props, "size">.
Step by step
01Write the props type
List each prop with its type. Required props have no ?. Use a union of literals for a prop with a fixed set of choices.
type BadgeProps = {
label: string; // required
tone?: "info" | "success" | "warning"; // optional, three choices
count?: number; // optional
};
function Badge({ label, tone = "info", count }: BadgeProps) {
return (
<span className={"badge badge-" + tone}>
{label}
{count !== undefined && <b> {count}</b>}
</span>
);
}02See what the checker rejects
Each of these lines is an error at the place the component is used. The messages below are the real tsc output.
<Button variant="danger">Delete</Button>
// error TS2322: Type '"danger"' is not assignable to type '"primary" | "secondary" | undefined'.
<Card title="Hi">Body text</Card> // Card's props are only { title: string }
// error TS2322: Type '{ children: string; title: string; }' is not assignable to type 'IntrinsicAttributes & { title: string; }'.
// Property 'children' does not exist on type 'IntrinsicAttributes & { title: string; }'.
<Button onClick={() => {}}>Save</Button> // ButtonProps has no onClick yet
// error TS2322: Type '{ children: string; onClick: () => void; }' is not assignable to type 'IntrinsicAttributes & ButtonProps'.
// Property 'onClick' does not exist on type 'IntrinsicAttributes & ButtonProps'.03Type children with ReactNode
children: ReactNode accepts text, elements, lists and null. A narrower type like ReactElement rejects plain text, which is rarely what you want.
import type { PropsWithChildren, ReactElement } from "react";
// Same as { title: string; children?: ReactNode }
function Panel({ title, children }: PropsWithChildren<{ title: string }>) {
return <section><h2>{title}</h2>{children}</section>;
}
function OnlyElement({ children }: { children: ReactElement }) {
return <div>{children}</div>;
}
<OnlyElement>text</OnlyElement>
// error TS2747: 'OnlyElement' components don't accept text as child elements. Text in JSX has the type 'string',
// but the expected type of 'children' is 'ReactElement<unknown, string | JSXElementConstructor<any>>'.04Borrow every native prop
Join ComponentProps<"button"> with your own props, take out the ones you handle, and spread the rest onto the real element. Callers can now pass onClick, disabled, type and aria-label without you listing them.
import type { ComponentProps } from "react";
type ButtonProps = ComponentProps<"button"> & {
variant?: "primary" | "secondary" | "danger";
};
export function Button({ variant = "primary", className, ...rest }: ButtonProps) {
return <button className={"btn btn-" + variant + (className ? " " + className : "")} {...rest} />;
}05Resolve name clashes with Omit
An <input> already has a size prop (a number). Declaring your own size: "sm" | "lg" on top clashes. With an interface you get a clear error; with & the conflicting prop silently becomes never (a type with no allowed values) and every use fails. Remove the native prop first.
interface InputProps extends ComponentProps<"input"> {
size: "sm" | "lg";
}
// error TS2430: Interface 'InputProps' incorrectly extends interface 'ClassAttributes<HTMLInputElement> & InputHTMLAttributes<HTMLInputElement>'.
// Type 'InputProps' is not assignable to type 'InputHTMLAttributes<HTMLInputElement>'.
// Types of property 'size' are incompatible.
// Type '"sm" | "lg"' is not assignable to type 'number | undefined'.
// Type 'string' is not assignable to type 'number'.
// Fix: take the native one out first.
type FixedInputProps = Omit<ComponentProps<"input">, "size"> & { size?: "sm" | "lg" };06Decide about ref
In React 19, ComponentProps<"button"> includes ref, and spreading ...rest passes it to the <button>. If your component shouldn't take a ref, use ComponentPropsWithoutRef<"button">, and a caller who passes one gets an error.
function NoRef(props: ComponentPropsWithoutRef<"button">) {
return <button {...props} />;
}
<NoRef ref={buttonRef}>b</NoRef>
// error TS2322: Type '{ children: string; ref: RefObject<HTMLButtonElement | null>; }' is not assignable to type
// 'IntrinsicAttributes & Omit<DetailedHTMLProps<ButtonHTMLAttributes<HTMLButtonElement>, HTMLButtonElement>, "ref">'.
// Property 'ref' does not exist on type 'IntrinsicAttributes & Omit<DetailedHTMLProps<ButtonHTMLAttributes<HTMLButtonElement>, HTMLButtonElement>, "ref">'.Try it yourself
- 1
Add a size
In the Button example add
"lg"to thesizeunion and a.btn-lgCSS rule, then use<Button size="lg">Big</Button>. Predict the class name the log prints for it before running (btn btn-primary btn-lg). - 2
Pass a ref through
Add
const ref = useRef<HTMLButtonElement>(null)inApp(importuseRef), passref={ref}to the Save button, and logref.current?.textContentin thesetTimeout. Predict: does it logSave? (Yes: in React 19refis inComponentProps<"button">and travels through...rest.) - 3
Make the footer required
In the Card example change
footer?: ReactNodetofooter: ReactNode. Nothing changes in the preview, because types are stripped. Which cards wouldtscnow reject? (All but theElementcard:Property 'footer' is missing in type ....)
Code & diagrams
`onClick`, `type`, `disabled` and `aria-label` were never declared in `ButtonProps`; they come from `ComponentProps<"button">` and reach the real button through `...rest`. The disabled button ignores the click, so `deleted` never logs. `ghost` is a type error (shown in the comment), but the preview strips types, so it still renders with a made-up class.
Preview
Console
Nothing logged yet.Expected console output
Save: btn btn-primary btn-md
saved
Cancel: btn btn-secondary btn-sm
cancelled
Delete: btn btn-danger btn-md (disabled)
Ghost: btn btn-ghost btn-md extraEvery child here is a valid `ReactNode`: text, a number, an element, an array of strings, `null` and `false` (from `loggedIn && ...`). `null`, `false` and `undefined` render nothing. `NoteCard` takes exactly Card's props because `ComponentProps<typeof Card>` reads them from the component itself; change `CardProps` and `NoteCard` follows.
Preview
Console
Nothing logged yet.Expected console output
Text -> [Plain text]
Number -> [42]
Element -> [Bold words]
Array -> [one two three]
Nothing -> []
Note: Wrapped -> [Same props, new title]interface BaseProps {
id: string;
}
interface BaseProps { // same name: the two interfaces MERGE
label: string;
}
interface ButtonProps extends BaseProps {
variant: "primary" | "danger";
}
// The same with type aliases: no merging, & instead of extends.
type Variant = "primary" | "danger"; // unions need `type`
type ButtonProps2 = { id: string; label: string } & { variant: Variant };
const b: ButtonProps = { id: "a", variant: "primary" };
// error TS2741: Property 'label' is missing in type '{ id: string; variant: "primary"; }' but required in type 'ButtonProps'.
// (label came from the second, merged BaseProps declaration)Break it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Type children as JSX.Element
Write function Panel({ children }: { children: JSX.Element }) and use <Panel>Hello</Panel>.
Break #2
Spread native props before your own class
In Button, stop destructuring className (so it stays inside rest), build classes from the variant and size only, and render <button className={classes} {...rest} />. Use <Button className="extra">Ghost</Button>.
Myth vs fact
Myth
Always use React.FC to type components.
Fact
React.FC<Props> works, but it adds nothing important today: since the React 18 types it no longer adds children, and it makes generic components awkward. Most code types the props parameter directly: function Card(props: CardProps).
Myth
interface is faster or more correct than type for props.
Fact
For props they are interchangeable. interface gives clearer errors when extending and merges same-name declarations; type handles unions and intersections. Pick one style and stay consistent.
Myth
children is special and always allowed.
Fact
It's an ordinary prop. If your props type doesn't declare it, passing children is a type error: Property 'children' does not exist on type ....
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
ReactNodein React 19's types includesPromise<AwaitedReactNode>andbigint, because server components can render promises and React renders bigints as text.ReactElement'spropsnow default tounknown(notany), so code that readselement.props.somethingneeds a typed element:ReactElement<{ something: string }>. - ▸
ComponentProps<"button">isJSX.IntrinsicElements["button"], i.e.DetailedHTMLProps<ButtonHTMLAttributes<HTMLButtonElement>, HTMLButtonElement>, which includesrefandkey.ComponentPropsWithoutRefwraps it inPropsWithoutRef. Intersections that conflict collapse tonever, and TS 5.x reports it:The intersection 'InputProps' was reduced to 'never' because property 'size' has conflicting types in some constituents. - ▸
Excess-property checks only apply to fresh object literals. JSX attributes are checked like a literal (so
nmaeis caught), but<Card {...someObject} />with extra keys is allowed. Spreads are a place where typos can slip through. - ▸
React 19 removed
propTypeschecking for function components (thepropTypesfield still exists in the types, marked deprecated and ignored). TypeScript props are now the main contract; for runtime validation of outside data, validate at the boundary instead.
Remember this
- 1
Props are one object, so you type them with an object type. You can write it with
type ButtonProps = { label: string }(a type alias: a name for any type) orinterface ButtonProps { label: string }(an interface: a name for an object shape). For props, both work. The differences: aninterfacecanextendanother and reports a conflict clearly; two interfaces with the same name merge (declaration merging), which is useful for libraries and surprising in apps. Atypecan also name unions ("sm" | "lg") and combine shapes with&(an intersection). Many teams picktypefor props because unions come up so often; the important thing is to be consistent. - 2
Optional props get a
?:size?: "sm" | "md". Inside the component its type is"sm" | "md" | undefined, so strict mode makes you handle the missing case. The neatest way is a default value in the destructuring,{ size = "md" }, which removesundefinedfrom the type inside the function. Required props have no?, and leaving one out is an error at the place where the component is used. - 3
Unions for variants: a union type lists the allowed values,
variant?: "primary" | "secondary" | "danger". Each item is a string literal type (a type with exactly one allowed value). Callers get autocomplete for the three choices, andvariant="ghost"is an error. This is much safer thanvariant: string, and it replaces the oldpropTypesoneOfruntime checks, which React 19 no longer runs. - 4
**
childrenandReactNode:**childrenis an ordinary prop that holds whatever you put between the tags. Type it as **ReactNode: React's type for anything it can render**: JSX elements, strings, numbers,bigint, booleans,null,undefined, arrays (any iterable) of those, portals, and in React 19 promises. **PropsWithChildren<P>** is a shortcut that addschildren?: ReactNodeto your props. Don't useJSX.ElementorReactElementfor children unless you really need exactly one element: they reject plain text. Since the React 18 types, a component **doesn't getchildrenautomatically**, not even throughReact.FC; if you don't declare it, passing children is an error. - 5
**
ComponentProps<"button">** gives you every prop a real<button>accepts:onClick,disabled,type,aria-*,className,childrenand about 300 more. Combine it with your own props using&and spread the rest onto the element:{ variant, ...rest }then<button {...rest} />. Your component now behaves like a native button, so accessibility attributes and event handlers pass straight through.ComponentProps<typeof Card>does the same for one of your components, so a wrapper can reuse its props without copying them. - 6
**
ComponentPropsWithoutRef<"button">is the same list withoutref**. In React 19,refis an ordinary prop on function components (Topic 8.3), soComponentProps<"button">includesrefand spreading it onto<button>forwards the ref with noforwardRef. Use theWithoutRefversion when your component must not accept a ref, or when you're typing older code that still usesforwardRef. When your own prop has the same name as a native one but a different type (asizeof"sm" | "lg"versus the input's numericsize), remove the native one first withOmit<ComponentProps<"input">, "size">, or the types clash.
Explain it without notes
When would you choose type and when interface for props?
What is ReactNode, and why is it usually better than JSX.Element for children?
How do you build a Button that accepts all native <button> props plus a variant? What about ref in React 19?
What goes wrong when your prop has the same name as a native prop, and how do you fix it?
Practice
Write an Alert component with tone: "info" | "error" (required), an optional title?: string, and children. Render one of each tone.
Write a TextField that accepts every native <input> prop plus a required label: string, and renders a <label> wrapping the text and the input.
Trade-offs
- ↔
Accepting every native prop (
ComponentProps<"button">) makes a component flexible and accessible, but it also lets callers override things you rely on (className,onClick). Decide which props you merge and which you forbid (Omit). - ↔
Very precise prop types (many unions and overloads) catch more mistakes but produce longer error messages. Keep public component types readable.
Done when you can
I can type props with
typeorinterfaceand explain the differences.I can mark props optional and give them defaults so
undefineddisappears inside the component.I can use a union of string literals for variants.
I type
childrenasReactNodeor usePropsWithChildren.I can extend native element props with
ComponentProps<"button">, and I know when to useComponentPropsWithoutRef.I can fix a clash with a native prop using
Omit.