TypeScript + React in Practice #2: Typing props and children
In #1 Getting started and setup we added props types to a first component. This post covers the practical decisions of props typing — how far to narrow, when to branch with a union, and how to receive children.
type vs interface — which to use #
We compared the two in basics #3 interface and type alias. For React component props, the answer fits in one line:
Use
typefor component props.
Two reasons.
- Props are usually a single object shape, and declaration merging isn’t really needed. It’s only meaningful for extending library types — rarely useful in app code.
- It’s good for advanced compositions like unions and conditionals (we’ll see this shortly with union props).
type ButtonProps = {
label: string;
onClick: () => void;
};
function Button({ label, onClick }: ButtonProps) {
return <button onClick={onClick}>{label}</button>;
}Required vs optional props #
Optional props are marked with ?. Callers can omit them; the receiver must handle string | undefined.
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="프로필" size={64} /> // OK
Two patterns to notice here.
1) Defaults at destructuring time
Giving a default like alt = '' at destructuring time narrows alt to string throughout the component body — no alt | undefined branch every time.
2) optional vs null
? (can be omitted) and null (explicitly empty) differ when expressing “no value.” Props usually go ?. Use null consciously only when you need to express “the value was collected but is empty,” like for form inputs.
Forwarding common HTML attributes — ComponentProps
#
Components like a button or input usually wrap an HTML element. Manually redefining onClick, className, and disabled every time is wasteful. ComponentProps lets you receive the whole set and extend.
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}
/>
);
}With this, callers can use any HTML attribute like <Button onClick={...} disabled aria-label="..."> freely, and autocomplete works correctly.
Note: People used to write
React.ButtonHTMLAttributes<HTMLButtonElement>.ComponentProps<'button'>is shorter with the same effect, so this is more common today.
Union props — “this OR that, only one allowed” #
The truly tricky pattern is when props have a mutually exclusive relationship. For example, when a button renders as <button> it needs onClick; when it renders as a link (<a>) it needs href. Cleanly, you don’t want both at once.
The answer is a discriminated union.
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 the shape the caller chose, different props are required.
<Button as="button" onClick={() => alert('!')}>클릭</Button> // OK
<Button as="a" href="/about">소개</Button> // OK
<Button as="button">클릭</Button> // ✗ onClick missing
<Button as="a" onClick={...}>...</Button> // ✗ a has no onClick
<Button as="button" href="/x">...</Button> // ✗ button has no href
A field like as that branches the cases is called a discriminator. In React, names like kind, type, and variant are common, but type clashes with HTML attribute names — prefer as or kind.
Typing children — start with ReactNode
#
Components that take child elements are very common. What type should children get?
For most cases, React.ReactNode. It includes everything React can render — strings, numbers, elements, arrays, null, and undefined.
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="안녕">
<p>본문 단락</p>
<button>버튼</button>
</Card>Sometimes you want to narrow further — to function-shaped children (render prop) or children that only accept specific components.
1) render prop When children is a function, write its signature.
type DataLoaderProps<T> = {
load: () => Promise<T>;
children: (data: T) => React.ReactNode;
};
function DataLoader<T>({ load, children }: DataLoaderProps<T>) {
// implementation passing the load result to children(data) — see #3, #6
return null;
}We cover generics in earnest in #5 Context and generic components. For now, just remember “children’s type can be a function like () => ReactNode too.”
2) Receiving only specific elements as children — rarely recommended
Demands like “make <List>’s children only accept <ListItem>” come up often, but TypeScript’s JSX typing doesn’t express that naturally. Usually it’s cleaner to take a data prop and have the component render directly rather than enforcing children types.
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
#
Because the children pattern is common, React provides a helper.
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 it’s almost the same as { children?: React.ReactNode } & Props. Almost matters — PropsWithChildren makes children optional. If you also want to allow calls without children, this is convenient.
It’s fine to write it explicitly — both patterns are common, so match team convention.
Props composition — reusing another component’s props #
Big components usually use smaller ones inside. When you want to pass some of the inner component’s props through, it’s good to pull that component’s props type directly.
import type { ComponentProps } from 'react';
function Input(props: ComponentProps<'input'>) {
return <input {...props} />;
}
// Labeled Input — pass 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>
);
}ComponentProps<typeof Input> is the trick. It pulls the props type of the Input component as-is. When Input’s props change later, LabeledInput auto-updates.
You’ll use this pattern almost daily when building a design system. Components like an “IconButton with an extra icon prop on top of Button” compose with ComponentProps<typeof Button> & { icon: ... }.
Readonly array/object props #
When props are arrays or objects, if the receiver won’t mutate them, taking them as readonly is safer.
type TagListProps = {
tags: readonly string[];
};
function TagList({ tags }: TagListProps) {
// tags.push('new') ← ✗ readonly
return (
<ul>{tags.map((t) => <li key={t}>{t}</li>)}</ul>
);
}Mutating data received via props inside a React component is almost always a bug. Adding readonly catches that mistake at compile time.
Wrap-up #
This post covered:
- Use
typefor component props - Mark optional props with
?, default at destructuring time - Forward HTML attributes with
ComponentProps<'button'> - Use a discriminated union for mutually exclusive props (
as: 'button' | 'a') React.ReactNodeis the default for children; for function children, write the signaturePropsWithChildrenis a helper that adds optional children- Compose another component’s props with
ComponentProps<typeof X> - Use
readonlyfor array/object props you don’t mutate
In the next post (#3 Typing hooks) we cover how to type built-in hooks like useState, useReducer, useRef, useCallback, and useMemo, and how much to leave to inference.