Command Palette

Search for a command to run...

Hectal
PHASE 12Intermediate ~27 min· topic 2 of 6

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 can extend other interfaces.
Optional prop
A prop marked with ?. It may be left out, so its type includes undefined.
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?: ReactNode to 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.

Badge.tsxwhole filetsx
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.

App.tsxwhole filetsx
<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.

Panel.tsxwhole filetsx
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.

Button.tsxwhole filetsx
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} />;
}
Borrow every native propdiagram
Rendering diagram…

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.

Input.tsxwhole filetsx
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.

NoRef.tsxwhole filetsx
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. 1

    Add a size

    In the Button example add "lg" to the size union and a .btn-lg CSS 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. 2

    Pass a ref through

    Add const ref = useRef<HTMLButtonElement>(null) in App (import useRef), pass ref={ref} to the Save button, and log ref.current?.textContent in the setTimeout. Predict: does it log Save? (Yes: in React 19 ref is in ComponentProps<"button"> and travels through ...rest.)

  3. 3

    Make the footer required

    In the Card example change footer?: ReactNode to footer: ReactNode. Nothing changes in the preview, because types are stripped. Which cards would tsc now reject? (All but the Element card: Property 'footer' is missing in type ....)

Code & diagrams

A Button with variants that accepts every native button prop New tab

`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

Press Run to see the page.

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 extra
children as ReactNode, a footer slot, and ComponentProps<typeof Card> New tab

Every 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

Press Run to see the page.

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]
type versus interface: merging and extendingtsx
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>.

terminal
$ npx tsc --noEmit
── what you'll see ──
error TS2503: Cannot find namespace 'JSX'.

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>.

terminal
$ Preview
── what you'll see ──
The button renders with class="extra" only: the btn and variant classes are gone, and the button loses its colour.

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.

  • ▸

    ReactNode in React 19's types includes Promise<AwaitedReactNode> and bigint, because server components can render promises and React renders bigints as text. ReactElement's props now default to unknown (not any), so code that reads element.props.something needs a typed element: ReactElement<{ something: string }>.

  • ▸

    ComponentProps<"button"> is JSX.IntrinsicElements["button"], i.e. DetailedHTMLProps<ButtonHTMLAttributes<HTMLButtonElement>, HTMLButtonElement>, which includes ref and key. ComponentPropsWithoutRef wraps it in PropsWithoutRef. Intersections that conflict collapse to never, 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 nmae is caught), but <Card {...someObject} /> with extra keys is allowed. Spreads are a place where typos can slip through.

  • ▸

    React 19 removed propTypes checking for function components (the propTypes field 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. 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) or interface ButtonProps { label: string } (an interface: a name for an object shape). For props, both work. The differences: an interface can extend another and reports a conflict clearly; two interfaces with the same name merge (declaration merging), which is useful for libraries and surprising in apps. A type can also name unions ("sm" | "lg") and combine shapes with & (an intersection). Many teams pick type for props because unions come up so often; the important thing is to be consistent.

  2. 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 removes undefined from 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. 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, and variant="ghost" is an error. This is much safer than variant: string, and it replaces the old propTypes oneOf runtime checks, which React 19 no longer runs.

  4. 4

    **children and ReactNode:** children is 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 adds children?: ReactNode to your props. Don't use JSX.Element or ReactElement for children unless you really need exactly one element: they reject plain text. Since the React 18 types, a component **doesn't get children automatically**, not even through React.FC; if you don't declare it, passing children is an error.

  5. 5

    **ComponentProps<"button">** gives you every prop a real <button> accepts: onClick, disabled, type, aria-*, className, children and 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. 6

    **ComponentPropsWithoutRef<"button"> is the same list without ref**. In React 19, ref is an ordinary prop on function components (Topic 8.3), so ComponentProps<"button"> includes ref and spreading it onto <button> forwards the ref with no forwardRef. Use the WithoutRef version when your component must not accept a ref, or when you're typing older code that still uses forwardRef. When your own prop has the same name as a native one but a different type (a size of "sm" | "lg" versus the input's numeric size), remove the native one first with Omit<ComponentProps<"input">, "size">, or the types clash.

Explain it without notes

01

When would you choose type and when interface for props?

02

What is ReactNode, and why is it usually better than JSX.Element for children?

03

How do you build a Button that accepts all native <button> props plus a variant? What about ref in React 19?

04

What goes wrong when your prop has the same name as a native prop, and how do you fix it?

Practice

01

Write an Alert component with tone: "info" | "error" (required), an optional title?: string, and children. Render one of each tone.

02

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 type or interface and explain the differences.

  • I can mark props optional and give them defaults so undefined disappears inside the component.

  • I can use a union of string literals for variants.

  • I type children as ReactNode or use PropsWithChildren.

  • I can extend native element props with ComponentProps<"button">, and I know when to use ComponentPropsWithoutRef.

  • I can fix a clash with a native prop using Omit.