Props in Depth: Types, Children, and Spreading

Master typing every common shape of prop — basic, optional, function, object, array, union, and discriminated — plus typing children with ReactNode/ReactElement and using prop spreading and rest props responsibly.

Props are the only way data flows into a component, and in TypeScript, the props type is effectively the component's public contract. A precisely typed props interface tells every future caller exactly what's required, what's optional, and what shape each value must take — catching mistakes at compile time instead of in the browser. This lesson works through the full range of prop shapes you'll encounter day to day, from a single required string up through discriminated unions and rest-prop spreading.

Basic, optional, and default-valued props

The simplest props are primitives — strings, numbers, booleans — declared as fields on a type (or interface) named after the component. Consider type ButtonProps = { label: string; disabled?: boolean; onClick: () => void };. label and onClick are required: TypeScript errors if a caller omits either. disabled carries a ?, making it optional — callers may leave it out entirely, and inside the component it has type boolean | undefined unless you give it a default.

Default values are supplied by destructuring the props parameter with =: function Button({ label, disabled = false, onClick }: ButtonProps). This pattern reads cleanly, keeps the default next to the parameter it applies to, and narrows the type from boolean | undefined down to plain boolean inside the function body, so you don't need to handle undefined anywhere else in the component.

A common early mistake is making a prop optional when it should be required, purely to avoid passing it at every call site during development. Resist that — an optional prop that's actually always needed defeats the whole purpose of typed props, which is to catch a missing value before the app ever runs.

Function, object, and array props

A function prop is typed exactly like any function type: a parameter list and a return type, e.g. onClick: () => void, onChange: (value: string) => void, or onSelect: (id: number, item: Item) => void. Getting the parameter types right matters — if onChange is typed as (value: string) => void but you actually call it with an event object, TypeScript will catch the mismatch at the call site inside your component, not at the caller.

Object props take a nested type: type UserCardProps = { user: { name: string; email: string; avatarUrl?: string } }. For anything reused in more than one place, it's better practice to pull the nested shape into its own named type — type User = { name: string; email: string; avatarUrl?: string }; type UserCardProps = { user: User } — so the shape has one definition instead of being duplicated across every component that needs it.

Array props are typed with T[] or Array<T> — items: string[], users: User[], rows: Array<{ id: number; label: string }>. When a component renders a list from an array prop, remember each rendered item still needs a stable key, a rule that applies regardless of how the array prop itself is typed.

Union and discriminated union props

A plain union prop restricts a value to a fixed set of options: type ButtonProps = { variant: "primary" | "secondary" | "danger" }. This is far safer than typing variant: string, because TypeScript rejects any string that isn't one of the three listed literals, and your editor can autocomplete the valid choices.

A discriminated union goes further: it types a prop as one of several *object* shapes that share a common 'tag' field, letting TypeScript narrow which other fields are available based on that tag. For example, a StatusBadgeProps might be { status: "loading" } | { status: "error"; message: string } | { status: "success"; data: Result }. Inside the component, checking if (props.status === "error") narrows the type so that props.message is known to exist only in that branch — TypeScript would flag props.message as an error anywhere else.

Discriminated unions are the idiomatic way to model 'this prop's other fields depend on this one field's value,' and they're far safer than making every field optional and hoping callers pass the right combination. The compiler enforces the combination for you.

children: ReactNode, ReactElement, spreading, and rest props

children is the special prop React passes for whatever's nested between a component's opening and closing tags. The correct, most permissive type for it is React.ReactNode — it accepts strings, numbers, JSX elements, arrays of any of those, null, undefined, and booleans (which React silently renders as nothing). Use type CardProps = { children: React.ReactNode } for a component that should accept essentially anything as its content.

React.ReactElement is a narrower type used when a component specifically needs *one JSX element* rather than arbitrary content — for instance, a component that will clone or inspect its single child element with React.cloneElement. Reach for ReactElement only when you genuinely need that guarantee; for everyday 'wrap whatever content' components, ReactNode is almost always the right choice.

Prop spreading (<Button {...commonProps} />) forwards every property of an object as individual props in one expression, which is convenient for passing through a bag of shared configuration. Rest props (function Button({ label, ...rest }: ButtonProps & React.ButtonHTMLAttributes<HTMLButtonElement>)) capture 'everything else' the caller passed so you can forward it onward — commonly used to let a wrapper component transparently accept any native <button> attribute (like type or aria-label) without explicitly listing every one.

Both patterns trade a little type precision for a lot of flexibility, so use them deliberately: spreading an untyped object can silently pass through props you didn't intend to expose, and forgetting to type the rest parameter leaves it as any. Prefer spreading a well-typed object, and always give a rest-props parameter an explicit type such as React.HTMLAttributes<HTMLDivElement>.

Core prop shapes, including the module's reference example

Intersecting with React.HTMLAttributes<HTMLDivElement> lets Card accept native div attributes (className, onClick, id, ...) without listing each one. Rest props (...rest) must be spread onto the underlying element last if you want callers able to override built-in attributes.

type ButtonProps = {
  label: string;
  disabled?: boolean;
  onClick: () => void;
};

// Union of literal strings
type Variant = "primary" | "secondary" | "danger";

// Discriminated union
type StatusBadgeProps =
  | { status: "loading" }
  | { status: "error"; message: string }
  | { status: "success"; data: string };

// children typed as ReactNode, plus rest props forwarding
type CardProps = React.HTMLAttributes<HTMLDivElement> & {
  children: React.ReactNode;
};

function Card({ children, ...rest }: CardProps) {
  return <div className="card" {...rest}>{children}</div>;
}

Code Examples

TypeScript checks every usage of Button against ButtonProps: the first call supplies both required props and compiles; the second omits label, a required field, and fails to compile. Expected result: First renders fine; second is a compile-time error, not a runtime one.

Because StatusBadgeProps is a discriminated union on status, TypeScript requires message whenever status is "error" and forbids passing data or message when status is "loading" — the compiler enforces the correct field combination automatically.

if (props.status === "error") { props.message } Common mistake: Making every field optional instead of using a discriminated union would let callers pass status: "error" with no message and no compile error.

<Button label="Save" onClick={() => console.log("saved")} />;
<Button onClick={() => {}} />; // Error: missing required prop 'label'
function StatusBadge(props: StatusBadgeProps) {
  if (props.status === "loading") {
    return <span>Loading…</span>;
  }
  if (props.status === "error") {
    return <span className="error">{props.message}</span>;
  }
  return <span className="success">{props.data}</span>;
}

<StatusBadge status="error" message="Request failed" />;
<StatusBadge status="error" />; // Error: 'message' is required when status is 'error'

Props flow one direction

Data always flows down through props; a child talks back to its parent only by calling a function prop the parent provided.

Parent component
   |  passes props down
   v
<Button label="Save" disabled={false} onClick={fn} />
   |
   v
Child component reads ButtonProps, never writes back to the parent

Important Rules

Name a component's props type <ComponentName>Props by convention, so it's easy to find and reuse.

Type children as React.ReactNode unless the component specifically needs exactly one element, in which case use React.ReactElement.

Prefer a union of string literals ("sm" | "md" | "lg") over a bare string whenever a prop has a fixed set of valid values.

Use a discriminated union, not a bag of optional fields, whenever a prop's valid combination of fields depends on another field's value.

Always give a rest-props parameter an explicit type (e.g. React.HTMLAttributes<HTMLDivElement>) instead of leaving it implicitly any.

Where this shows up

Component libraries like design systems lean heavily on discriminated unions and literal unions to make impossible states impossible to construct — a Toast component's props might be a discriminated union on type: "info" | "error" so that only error toasts require a retry callback, catching missing-callback bugs before code review ever starts.

type ButtonProps = { variant: string };

type ButtonProps = { variant: "primary" | "secondary" | "danger" };

A bare string accepts any typo ("primry") silently; a literal union rejects anything outside the allowed set at compile time.

type StatusBadgeProps = { status: string; message?: string; data?: string };

type StatusBadgeProps = { status: "loading" } | { status: "error"; message: string } | { status: "success"; data: string };

Optional fields for every variant let a caller mix and match incompatible props (e.g. status "loading" with a data field); a discriminated union enforces exactly one valid combination per status.

[multiple-choice] Which type should children normally be given when a component should accept any renderable content? A. string B. React.ReactElement C. React.ReactNode D. any

C. React.ReactNode — React.ReactNode covers strings, numbers, elements, arrays, null, undefined, and booleans — everything React can actually render as children — making it the correct default for a flexible 'children' prop.

[multiple-choice] What is the main benefit of a discriminated union over a set of all-optional props? A. It makes the code shorter to write B. It removes the need for a props type entirely C. TypeScript enforces exactly one valid combination of fields per variant D. It allows any field to be passed with any status

C. TypeScript enforces exactly one valid combination of fields per variant — A discriminated union ties each variant's required fields to a shared tag field, so the compiler rejects invalid combinations (like an "error" status missing its message) that all-optional fields would silently allow.

[true-false] True or False: disabled?: boolean and disabled: boolean = false mean exactly the same thing inside a component. A. True B. False

B. False — disabled?: boolean alone leaves the value as boolean | undefined inside the component; only destructuring with a default ({ disabled = false }) narrows it to plain boolean.

Define a CardProps type for a component that must accept children: React.ReactNode, an optional title: string, and should also forward any native div attributes (like onClick or id) that a caller passes.

type CardProps = React.HTMLAttributes<HTMLDivElement> & { children: React.ReactNode; title?: string; }; function Card({ children, title, ...rest }: CardProps) { return ( <div {...rest}> {title && <h3>{title}</h3>} {children} </div> ); }

Intersect a custom object type with React.HTMLAttributes<HTMLDivElement> using &.