props와 children 타이핑
props 타입 정의 패턴, ReactNode와 ReactElement와 JSX.Element 차이, PropsWithChildren을 쓰는 경우, discriminated union까지 한 번에 다룹니다.
16장에서 첫 컴포넌트에 props 타입을 달아 봤습니다. 본 챕터에서는 props 타이핑의 실전 결정들을 다루겠습니다. 어디까지 좁히고, 언제 union으로 분기하고, children은 어떻게 받을지까지.
본 챕터의 패턴들은 4장 (컴포넌트와 props)의 JavaScript 버전을 TypeScript 위에 다시 올린 결과입니다. 그리고 24장 (Server vs Client Components)에서 server → client 컴포넌트로 props가 넘어갈 때의 직렬화 제약도 본 챕터의 타입 위에서 한 번 더 짚게 됩니다.
type vs interface — 둘 중 무엇을 써야 할까 #
리액트 컴포넌트 props에서는 다음 한 줄로 답이 정해집니다.
컴포넌트 props에는
type을 쓴다.
이유는 두 가지입니다.
- props는 객체 모양 하나로 끝이고, 선언 병합 (declaration merging)이 굳이 필요하지 않습니다. 라이브러리 타입을 확장할 때나 의미 있는 기능이라 앱 코드에서는 거의 쓸 일이 없습니다.
- union이나 conditional 같은 고급 합성을 하기 좋습니다 (잠시 뒤 union props에서 보게 됩니다).
type ButtonProps = {
label: string;
onClick: () => void;
};
function Button({ label, onClick }: ButtonProps) {
return <button onClick={onClick}>{label}</button>;
}필수 prop과 선택 prop #
선택 prop은 **?**로 표시합니다. 호출하는 쪽에서 생략할 수 있고, 받는 쪽에서는 string | undefined처럼 다뤄야 합니다.
type AvatarProps = {
src: string;
alt?: string; // 생략 가능
size?: number; // 생략 가능
};
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
여기서 자주 보이는 두 가지 패턴이 있습니다.
1) 기본값은 구조 분해 시점에서
alt = ''처럼 디스트럭처링 시점에 기본값을 주면, 컴포넌트 본문에서는 alt가 항상 string으로 좁혀집니다. alt | undefined 분기를 매번 안 해도 됩니다.
2) optional vs null
“값이 없음"을 표현할 때 ? (생략 가능)와 null (명시적으로 비어 있음)은 다릅니다. props는 보통 ?로 갑니다. null은 폼 입력값처럼 “값을 모았지만 비어 있다"를 명시할 때만 의식적으로 씁니다.
자주 쓰는 HTML 속성 그대로 받기 — ComponentProps
#
버튼이나 입력 같은 컴포넌트는 보통 HTML 요소를 한 겹 감쌉니다. 이때 매번 onClick, className, disabled를 손으로 다시 정의하면 손해입니다. **ComponentProps**로 통째로 받아 확장할 수 있습니다.
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}
/>
);
}이렇게 짜면 <Button onClick={...} disabled aria-label="..."> 같은 모든 HTML 속성을 호출하는 쪽에서 자유롭게 쓸 수 있고, 자동완성도 제대로 뜹니다.
옛 자료에서는 React.ButtonHTMLAttributes<HTMLButtonElement>를 썼습니다. ComponentProps<'button'>가 더 짧고 같은 효과라 이 책은 이쪽을 씁니다.
Union props — “이거 또는 저거” 한쪽만 허용 #
진짜 어려운 패턴은 props 사이에 상호 배타 관계가 있을 때입니다. 버튼이 <button>으로 렌더될 때는 onClick이 필요하고, 링크 (<a>)로 렌더될 때는 href가 필요한 식입니다. 둘 다 한 번에 받지 않아야 깔끔합니다.
이때는 discriminated union이 답입니다.
type ButtonAsButton = {
as: 'button';
onClick: () => void;
href?: never; // 명시적으로 막음
};
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>;
}호출하는 쪽이 어떤 모양인지에 따라 다른 prop이 강제 됩니다.
<Button as="button" onClick={() => alert('!')}>클릭</Button> // OK
<Button as="a" href="/about">소개</Button> // OK
<Button as="button">클릭</Button> // ✗ onClick 누락
<Button as="a" onClick={...}>...</Button> // ✗ a에는 onClick 없음
<Button as="button" href="/x">...</Button> // ✗ button에는 href 없음
as 같이 분기 기준이 되는 필드를 discriminator라고 부릅니다. 리액트에서는 보통 kind, type, variant 같은 이름을 쓰는데, 이미 HTML 속성 이름과 겹치는 type보다는 as나 kind를 권합니다.
이 패턴은 18장 (hooks 타이핑)에서 useReducer의 action을 잡을 때 다시 만납니다. 본 챕터에서 기본 원리를 잡아 두면 18장이 가볍게 읽힙니다.
children 타이핑 — ReactNode를 기본으로
#
리액트 컴포넌트가 자식 엘리먼트를 받는 패턴은 매우 흔합니다. children에 어떤 타입을 줘야 할까요? 그 전에 비슷해 보이는 세 타입의 차이를 짚어야 합니다.
ReactNode vs ReactElement vs JSX.Element #
세 타입은 자주 헷갈리지만 의미가 명확히 다릅니다.
ReactNode— 리액트가 렌더링할 수 있는 모든 것. 문자열, 숫자, 엘리먼트, 배열,null,undefined, boolean까지 포함. 가장 넓은 타입.ReactElement—createElement()의 결과물 또는 JSX 표현식 한 개. 문자열 / 숫자 / null 등은 포함하지 않음.JSX.Element—ReactElement의 별칭에 가까운 글로벌 namespace 타입. 대부분의 경우ReactElement와 같다고 봐도 무방.
| 타입 | 포함 | 자주 쓰는 경우 |
|---|---|---|
ReactNode | 모든 렌더 가능한 것 (문자열, 숫자, 엘리먼트, 배열, null, …) | children의 표준, prop으로 받는 자식 |
ReactElement | JSX 표현식 한 개만 | 컴포넌트가 반환하는 한 개의 엘리먼트 (보통 추론에 맡김) |
JSX.Element | ReactElement와 거의 동등 | 옛 자료에서 자주 보임 |
거의 모든 children에는 **ReactNode**를 쓰면 됩니다.
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>함수 children (render prop) #
children이 함수일 때는 그 함수의 시그니처를 적어 줍니다.
type DataLoaderProps<T> = {
load: () => Promise<T>;
children: (data: T) => React.ReactNode;
};
function DataLoader<T>({ load, children }: DataLoaderProps<T>) {
// load 결과를 children(data)로 넘기는 구현 (구체적 구현은 21장)
return null;
}제네릭은 20장 (Context와 제네릭 컴포넌트)에서 본격적으로 다루겠습니다. 지금은 “children 타입은 (data: T) => ReactNode 같은 함수도 될 수 있다"만 기억해 두세요.
특정 엘리먼트만 받는 children — 거의 권하지 않음 #
“<List>의 children은 <ListItem>만 받게 해 줘” 같은 요구는 자주 들리지만, TypeScript의 JSX 타이핑은 그걸 자연스럽게 표현하기 어렵습니다. 보통은 children 대신 데이터 prop으로 받고 컴포넌트가 직접 렌더 하는 게 깔끔합니다.
type ListProps = {
items: { id: string; label: string }[];
};
function List({ items }: ListProps) {
return (
<ul>
{items.map((item) => (
<li key={item.id}>{item.label}</li>
))}
</ul>
);
}PropsWithChildren 헬퍼
#
children을 받는 패턴이 흔하다 보니 React에 헬퍼가 있습니다.
import type { PropsWithChildren } from 'react';
type CardProps = PropsWithChildren<{
title: string;
}>;
function Card({ title, children }: CardProps) {
return (
<section>
<h3>{title}</h3>
<div>{children}</div>
</section>
);
}내부적으로는 { children?: React.ReactNode } & Props와 거의 같습니다. 거의 같다는 게 중요한데, PropsWithChildren은 children을 선택으로 만듭니다.
PropsWithChildren을 쓸 경우 vs 직접 선언할 경우 #
- children이 필수가 아닐 때 (래퍼 컴포넌트, 옵션 슬롯) →
PropsWithChildren이 편리 - children이 항상 있어야 할 때 (Card, Modal 본문 같은 구조 강제) → 직접
children: React.ReactNode를 명시해 필수 표시 - children이 함수 (render prop) 일 때 → 무조건 직접 선언.
PropsWithChildren은ReactNode로 못 박음 - 팀 컨벤션이 정해져 있는 경우 → 그대로 따름. 두 패턴 모두 흔하니 큰 차이는 없음
이 책은 의도를 명확히 드러내고 싶을 때는 직접 선언, 단순 래퍼는 PropsWithChildren을 쓰는 식으로 혼용합니다.
Props 합성 — 다른 컴포넌트의 props 재사용 #
큰 컴포넌트는 보통 작은 컴포넌트를 안에서 씁니다. 안쪽 컴포넌트의 props 일부를 그대로 통과시키고 싶을 때, 그 컴포넌트의 props 타입을 직접 가져와 쓰는 게 좋습니다.
import type { ComponentProps } from 'react';
function Input(props: ComponentProps<'input'>) {
return <input {...props} />;
}
// 라벨이 붙은 Input — 안쪽 Input의 props를 그대로 통과
type LabeledInputProps = ComponentProps<typeof Input> & {
label: string;
};
function LabeledInput({ label, ...inputProps }: LabeledInputProps) {
return (
<label>
<span>{label}</span>
<Input {...inputProps} />
</label>
);
}ComponentProps<typeof Input>이 핵심입니다. Input이라는 컴포넌트가 받는 props 타입을 그대로 끌어옵니다. 나중에 Input의 props가 바뀌면 LabeledInput도 자동으로 따라 바뀝니다.
이 패턴은 디자인 시스템을 만들 때 거의 매일 쓰게 됩니다. “버튼 안에 아이콘 prop을 추가한 IconButton” 같은 컴포넌트도 ComponentProps<typeof Button> & { icon: ... } 같은 식으로 합성합니다.
readonly 배열 / 객체 props #
props가 배열이나 객체일 때, 받는 쪽에서 **수정하지 않을 거라면 readonly**로 받는 게 안전합니다.
type TagListProps = {
tags: readonly string[];
};
function TagList({ tags }: TagListProps) {
// tags.push('new') ← ✗ readonly
return (
<ul>{tags.map((t) => <li key={t}>{t}</li>)}</ul>
);
}리액트 컴포넌트가 props로 받은 데이터를 직접 수정하는 건 거의 항상 버그입니다. readonly를 붙여 두면 그런 실수가 컴파일 단계에서 잡힙니다. 4장 (컴포넌트와 props)의 “props는 읽기 전용” 원칙이 TypeScript 위에서 컴파일러에 의해 강제되는 모습입니다.
직접 해보기 #
Card와 Button 두 컴포넌트를 TypeScript로 짜고, Button은 discriminated union으로 button / link 모드를 지원하도록 만들어 봅니다.
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:
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:
import Card from './Card';
import Button from './Button';
function App() {
return (
<>
<Card title="버튼 데모">
<Button onClick={() => alert('클릭')}>버튼 모드</Button>
<Button as="a" href="https://schoolofweb.net">링크 모드</Button>
</Card>
</>
);
}
export default App;저장하면 두 버튼이 그려집니다. 이제 잘못된 조합을 시도해 보세요.
<Button as="a" onClick={() => {}}>X</Button> // ✗ link에 onClick 안 됨
<Button as="a">X</Button> // ✗ href 누락
<Button as="button" href="/x">X</Button> // ✗ button에 href 안 됨
세 가지 모두 에디터에서 즉시 빨간 줄이 그어집니다.
연습문제 #
Avatar컴포넌트를 만들고src(필수),alt(선택, 기본값''),size(선택, 기본값 40) 세 prop을 받게 작성해 보세요.<Avatar src="/x.png" />와<Avatar src="/x.png" size={64} />두 호출이 모두 컴파일 통과하는지 확인합니다.Card의children을 옵션으로 만들고 (PropsWithChildren), 호출 시 children을 빼 보세요. 컴파일 통과. 그 뒤 직접children: ReactNode로 필수 선언하면 같은 호출이 빨간 줄로 막히는 것을 비교합니다.Button의 discriminator를as대신kind로 바꿔 보세요.kind: 'button'/kind: 'link'로 union을 다시 잡고, 호출 코드도 따라 바꿉니다. 이름만 바뀌는 게 아니라 discriminator가 상호 배타 + 페이로드 강제의 핵심임을 직접 손에 익혀 봅니다.
한 줄 요약: 컴포넌트 props는
type으로 정의한다.?로 선택 prop, 디스트럭처링 시점에 기본값. HTML 속성은ComponentProps<'button'>으로 통째로 받는다. 상호 배타 props는 discriminated union (as: 'button' | 'a'). children의 표준 타입은ReactNode(가장 넓음).PropsWithChildren은 children을 optional로 추가하는 헬퍼. 다른 컴포넌트의 props 합성은ComponentProps<typeof X>. 수정하지 않을 배열 / 객체 props는readonly.
다음 챕터 #
다음 18장 hooks 타이핑에서는 useState, useReducer, useRef, useCallback, useMemo 같은 빌트인 hook 들의 타입을 어떻게 잡고 어디까지 추론에 맡길지를 다루겠습니다. 특히 useReducer의 action을 본 챕터의 discriminated union으로 잡아 reducer 안에서 자연스럽게 좁히는 패턴이 핵심입니다.