Contents
17 Chapter

Typing props and children

Patterns for defining prop types, the difference between ReactNode, ReactElement, and JSX.Element, when to reach for PropsWithChildren, and discriminated union props, all in one chapter.

In Chapter 16 we put a prop type on our first component. This chapter covers the real-world decisions you make while typing props — how far to narrow, when to branch with a union, and how to take children.

The patterns in this chapter are the result of putting Chapter 4 (Components and Props), the JavaScript version, back on top of TypeScript. And in Chapter 24 (Server vs Client Components), the serialization constraints that apply when props cross from a server to a client component will lean on the types we set up here.

type vs interface — which to use #

For React component props, one line decides it:

Use type for component props.

Two reasons.

  1. Props are just one object shape, so declaration merging is rarely useful. It only earns its keep when extending a library type, and almost never in app code.
  2. It composes well with unions and conditional types (we will see this in union props in a moment).
component props go with type
type ButtonProps = {
  label: string;
  onClick: () => void;
};

function Button({ label, onClick }: ButtonProps) {
  return <button onClick={onClick}>{label}</button>;
}

Required and optional props #

Optional props are marked with ?. The caller can omit them, and on the receiving side you have to handle them like string | undefined.

optional prop
type AvatarProps = {
  src: string;
  alt?: string;            // can be omitted
  size?: number;           // can be omitted
};

function Avatar({ src, alt = '', size = 40 }: AvatarProps) {
  return <img src={src} alt={alt} width={size} height={size} />;
}

<Avatar src="/me.png" />                       // OK
<Avatar src="/me.png" alt="profile" size={64} /> // OK

Two patterns show up often here.

1) Defaults at the destructuring site

When you give a default at destructuring time like alt = '', alt is narrowed to string throughout the component body. You do not have to write an alt | undefined branch every time.

2) optional vs null

When you want to express “no value”, ? (can be omitted) and null (explicitly empty) are different. Props usually go with ?. null is something you use deliberately, like a form input value, to mean “we gathered a value but it is empty”.

Receiving common HTML attributes as-is — ComponentProps #

Components like a button or an input usually wrap an HTML element. Manually redefining onClick, className, and disabled every time is a loss. You can grab the whole set with ComponentProps and extend it.

existing button attributes as-is + extra prop
import type { ComponentProps } from 'react';

type ButtonProps = ComponentProps<'button'> & {
  variant?: 'primary' | 'secondary';
};

function Button({ variant = 'primary', className, ...rest }: ButtonProps) {
  return (
    <button
      className={`btn btn-${variant} ${className ?? ''}`}
      {...rest}
    />
  );
}

Written this way, the caller can freely use every HTML attribute like <Button onClick={...} disabled aria-label="...">, and autocomplete works correctly.

Older material used React.ButtonHTMLAttributes<HTMLButtonElement>. ComponentProps<'button'> is shorter and has the same effect, so this book uses this one.

Union props — “one or the other, not both” #

The pattern that is actually hard is when props are in a mutually exclusive relationship. When a button renders as <button> it needs onClick; when it renders as a link (<a>) it needs href. The clean shape is to never accept both at once.

This is where discriminated unions are the answer.

button or link — exactly one
type ButtonAsButton = {
  as: 'button';
  onClick: () => void;
  href?: never;           // explicitly blocked
};

type ButtonAsLink = {
  as: 'a';
  href: string;
  onClick?: never;
};

type ButtonProps = (ButtonAsButton | ButtonAsLink) & {
  children: React.ReactNode;
};

function Button(props: ButtonProps) {
  if (props.as === 'a') {
    return <a href={props.href}>{props.children}</a>;
  }
  return <button onClick={props.onClick}>{props.children}</button>;
}

Depending on which shape the caller passes, different props are required.

union props in use
<Button as="button" onClick={() => alert('!')}>Click</Button>  // OK
<Button as="a" href="/about">About</Button>                    // OK

<Button as="button">Click</Button>                             // ✗ onClick missing
<Button as="a" onClick={...}>...</Button>                      // ✗ a has no onClick
<Button as="button" href="/x">...</Button>                     // ✗ button has no href

The field that drives the branching, like as, is called the discriminator. In React you typically see names like kind, type, or variant, but since type collides with an HTML attribute name, as or kind is preferable.

This pattern shows up again in Chapter 18 (Typing hooks) when we catch useReducer actions. Getting the basics down here makes Chapter 18 a lighter read.

Typing children — ReactNode is the default #

It is very common for a React component to take child elements. What type should children have? Before we answer, we need to clear up three types that look similar.

ReactNode vs ReactElement vs JSX.Element #

The three types are often confused, but their meanings are clearly different.

  • ReactNodeeverything React can render. Strings, numbers, elements, arrays, null, undefined, even booleans. The widest type.
  • ReactElement — the result of createElement(), or a single JSX expression. Strings, numbers, null, and so on are not included.
  • JSX.Element — essentially an alias for ReactElement in the global namespace. In most cases you can treat it as the same as ReactElement.
TypeWhat it includesTypical use
ReactNodeeverything renderable (string, number, element, array, null, …)the standard for children, child slots taken as props
ReactElementa single JSX expressiona single element a component returns (usually left to inference)
JSX.Elementessentially the same as ReactElementshows up in older material

Almost every children should use ReactNode.

the most common children pattern
type CardProps = {
  title: string;
  children: React.ReactNode;
};

function Card({ title, children }: CardProps) {
  return (
    <section className="card">
      <h3>{title}</h3>
      <div className="card-body">{children}</div>
    </section>
  );
}

<Card title="Hello">
  <p>body paragraph</p>
  <button>button</button>
</Card>

Function children (render prop) #

When children is a function, write the signature of that function.

render prop
type DataLoaderProps<T> = {
  load: () => Promise<T>;
  children: (data: T) => React.ReactNode;
};

function DataLoader<T>({ load, children }: DataLoaderProps<T>) {
  // implementation that passes the load result to children(data) (concrete impl is Chapter 21)
  return null;
}

Generics are covered in earnest in Chapter 20 (Context and generic components). For now, just remember that “the children type can also be a function like (data: T) => ReactNode”.

Children of a specific element only — rarely recommended #

Requests like “make <List> only accept <ListItem> as children” come up often, but TypeScript’s JSX typing has a hard time expressing that naturally. Usually it is cleaner to take a data prop instead of children and let the component render it.

take data instead of forcing children
type ListProps = {
  items: { id: string; label: string }[];
};

function List({ items }: ListProps) {
  return (
    <ul>
      {items.map((item) => (
        <li key={item.id}>{item.label}</li>
      ))}
    </ul>
  );
}

The PropsWithChildren helper #

Since taking children is such a common pattern, React ships a helper.

PropsWithChildren
import type { PropsWithChildren } from 'react';

type CardProps = PropsWithChildren<{
  title: string;
}>;

function Card({ title, children }: CardProps) {
  return (
    <section>
      <h3>{title}</h3>
      <div>{children}</div>
    </section>
  );
}

Internally this is almost equivalent to { children?: React.ReactNode } & Props. The “almost” matters — PropsWithChildren makes children optional.

When to use PropsWithChildren vs declare directly #

  • When children is not required (wrapper components, optional slots)PropsWithChildren is convenient
  • When children must always be there (structures like Card or Modal body) → declare children: React.ReactNode directly to mark it required
  • When children is a function (render prop) → declare it directly, no exceptions. PropsWithChildren pins it to ReactNode
  • When team convention is set → follow it. Both patterns are common, the difference is small

This book mixes the two: declare directly when intent matters, use PropsWithChildren for simple wrappers.

Composing props — reusing another component’s props #

A bigger component often uses smaller ones inside. When you want to pass some props straight through to an inner component, pull that component’s props type directly.

take the inner component's props as-is
import type { ComponentProps } from 'react';

function Input(props: ComponentProps<'input'>) {
  return <input {...props} />;
}

// labeled Input — passes through Input's props as-is
type LabeledInputProps = ComponentProps<typeof Input> & {
  label: string;
};

function LabeledInput({ label, ...inputProps }: LabeledInputProps) {
  return (
    <label>
      <span>{label}</span>
      <Input {...inputProps} />
    </label>
  );
}

The key is ComponentProps<typeof Input>. It pulls in the props type of the component named Input directly. When Input’s props change later, LabeledInput follows automatically.

You end up using this pattern almost daily when building a design system. Components like “IconButton, a button with an icon prop added” compose as ComponentProps<typeof Button> & { icon: ... }.

readonly array / object props #

When a prop is an array or an object and you are not going to mutate it on the receiving side, take it as readonly to stay safe.

readonly props
type TagListProps = {
  tags: readonly string[];
};

function TagList({ tags }: TagListProps) {
  // tags.push('new')  ← ✗ readonly
  return (
    <ul>{tags.map((t) => <li key={t}>{t}</li>)}</ul>
  );
}

A React component mutating data it received through props is almost always a bug. With readonly attached, the compiler catches that kind of mistake at compile time. The “props are read-only” principle from Chapter 4 (Components and props) is enforced by the compiler when you sit on top of TypeScript.

Try it yourself #

Let’s write Card and Button in TypeScript, with Button supporting both button and link modes via a discriminated union.

src/Card.tsx:

src/Card.tsx
import type { PropsWithChildren } from 'react';

type CardProps = PropsWithChildren<{
  title: string;
}>;

function Card({ title, children }: CardProps) {
  return (
    <section style={{ padding: '16px', border: '1px solid #ccc', borderRadius: '8px', margin: '8px 0' }}>
      <h3>{title}</h3>
      <div>{children}</div>
    </section>
  );
}

export default Card;

src/Button.tsx:

src/Button.tsx
import type { ReactNode } from 'react';

type ButtonAsButton = {
  as?: 'button';
  onClick: () => void;
  href?: never;
};

type ButtonAsLink = {
  as: 'a';
  href: string;
  onClick?: never;
};

type ButtonProps = (ButtonAsButton | ButtonAsLink) & {
  children: ReactNode;
};

function Button(props: ButtonProps) {
  if (props.as === 'a') {
    return <a href={props.href}>{props.children}</a>;
  }
  return <button onClick={props.onClick}>{props.children}</button>;
}

export default Button;

src/App.tsx:

src/App.tsx
import Card from './Card';
import Button from './Button';

function App() {
  return (
    <>
      <Card title="Button demo">
        <Button onClick={() => alert('clicked')}>Button mode</Button>
        <Button as="a" href="https://schoolofweb.net">Link mode</Button>
      </Card>
    </>
  );
}

export default App;

Save and you should see two buttons drawn. Now try wrong combinations.

wrong combinations — see the underlines
<Button as="a" onClick={() => {}}>X</Button>        // ✗ no onClick on the link
<Button as="a">X</Button>                            // ✗ href missing
<Button as="button" href="/x">X</Button>             // ✗ no href on the button

All three get red underlines in the editor immediately.

Exercises #

  1. Write an Avatar component that takes src (required), alt (optional, default ''), and size (optional, default 40). Confirm that both <Avatar src="/x.png" /> and <Avatar src="/x.png" size={64} /> compile.
  2. Make Card’s children optional (with PropsWithChildren), then try calling it without children. It compiles. Now switch to declaring children: ReactNode directly to mark it required, and compare how the same call is now blocked with a red underline.
  3. Change Button’s discriminator from as to kind. Re-form the union with kind: 'button' / kind: 'link' and update the call sites to match. This is more than a rename — feel for yourself how the discriminator is the heart of mutual exclusion + forced payload.

In one line: Define component props with type. Mark optional props with ? and put defaults at destructuring time. Take HTML attributes wholesale with ComponentProps<'button'>. For mutually exclusive props, use a discriminated union (as: 'button' | 'a'). ReactNode is the standard children type (widest). PropsWithChildren is the helper that adds children as optional. Compose another component’s props with ComponentProps<typeof X>. Mark array / object props you do not mutate as readonly.

Next chapter #

In the next Chapter 18 Typing hooks we cover how to handle the types of built-in hooks like useState, useReducer, useRef, useCallback, and useMemo, and how much to leave to inference. In particular, catching useReducer actions with the discriminated union from this chapter so the reducer narrows naturally is the key pattern.

X