목차
17 장

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을 쓴다.

이유는 두 가지입니다.

  1. props는 객체 모양 하나로 끝이고, 선언 병합 (declaration merging)이 굳이 필요하지 않습니다. 라이브러리 타입을 확장할 때나 의미 있는 기능이라 앱 코드에서는 거의 쓸 일이 없습니다.
  2. union이나 conditional 같은 고급 합성을 하기 좋습니다 (잠시 뒤 union props에서 보게 됩니다).
컴포넌트 props는 type으로
type ButtonProps = {
  label: string;
  onClick: () => void;
};

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

필수 prop과 선택 prop #

선택 prop은 **?**로 표시합니다. 호출하는 쪽에서 생략할 수 있고, 받는 쪽에서는 string | undefined처럼 다뤄야 합니다.

optional prop
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**로 통째로 받아 확장할 수 있습니다.

기존 button 속성을 그대로 + 추가 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}
    />
  );
}

이렇게 짜면 <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이 강제 됩니다.

union props 호출
<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보다는 askind를 권합니다.

이 패턴은 18장 (hooks 타이핑)에서 useReducer의 action을 잡을 때 다시 만납니다. 본 챕터에서 기본 원리를 잡아 두면 18장이 가볍게 읽힙니다.

children 타이핑 — ReactNode를 기본으로 #

리액트 컴포넌트가 자식 엘리먼트를 받는 패턴은 매우 흔합니다. children에 어떤 타입을 줘야 할까요? 그 전에 비슷해 보이는 세 타입의 차이를 짚어야 합니다.

ReactNode vs ReactElement vs JSX.Element #

세 타입은 자주 헷갈리지만 의미가 명확히 다릅니다.

  • ReactNode — 리액트가 렌더링할 수 있는 모든 것. 문자열, 숫자, 엘리먼트, 배열, null, undefined, boolean까지 포함. 가장 넓은 타입.
  • ReactElementcreateElement()의 결과물 또는 JSX 표현식 한 개. 문자열 / 숫자 / null 등은 포함하지 않음.
  • JSX.ElementReactElement의 별칭에 가까운 글로벌 namespace 타입. 대부분의 경우 ReactElement와 같다고 봐도 무방.
타입포함자주 쓰는 경우
ReactNode모든 렌더 가능한 것 (문자열, 숫자, 엘리먼트, 배열, null, …)children의 표준, prop으로 받는 자식
ReactElementJSX 표현식 한 개만컴포넌트가 반환하는 한 개의 엘리먼트 (보통 추론에 맡김)
JSX.ElementReactElement와 거의 동등옛 자료에서 자주 보임

거의 모든 children에는 **ReactNode**를 쓰면 됩니다.

가장 흔한 children 패턴
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이 함수일 때는 그 함수의 시그니처를 적어 줍니다.

render prop
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으로 받고 컴포넌트가 직접 렌더 하는 게 깔끔합니다.

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>
  );
}

PropsWithChildren 헬퍼 #

children을 받는 패턴이 흔하다 보니 React에 헬퍼가 있습니다.

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>
  );
}

내부적으로는 { children?: React.ReactNode } & Props와 거의 같습니다. 거의 같다는 게 중요한데, PropsWithChildren은 children을 선택으로 만듭니다.

PropsWithChildren을 쓸 경우 vs 직접 선언할 경우 #

  • children이 필수가 아닐 때 (래퍼 컴포넌트, 옵션 슬롯)PropsWithChildren이 편리
  • children이 항상 있어야 할 때 (Card, Modal 본문 같은 구조 강제) → 직접 children: React.ReactNode를 명시해 필수 표시
  • children이 함수 (render prop) 일 때 → 무조건 직접 선언. PropsWithChildrenReactNode로 못 박음
  • 팀 컨벤션이 정해져 있는 경우 → 그대로 따름. 두 패턴 모두 흔하니 큰 차이는 없음

이 책은 의도를 명확히 드러내고 싶을 때는 직접 선언, 단순 래퍼는 PropsWithChildren을 쓰는 식으로 혼용합니다.

Props 합성 — 다른 컴포넌트의 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**로 받는 게 안전합니다.

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>
  );
}

리액트 컴포넌트가 props로 받은 데이터를 직접 수정하는 건 거의 항상 버그입니다. readonly를 붙여 두면 그런 실수가 컴파일 단계에서 잡힙니다. 4장 (컴포넌트와 props)의 “props는 읽기 전용” 원칙이 TypeScript 위에서 컴파일러에 의해 강제되는 모습입니다.

직접 해보기 #

CardButton 두 컴포넌트를 TypeScript로 짜고, Button은 discriminated union으로 button / link 모드를 지원하도록 만들어 봅니다.

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 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 안 됨

세 가지 모두 에디터에서 즉시 빨간 줄이 그어집니다.

연습문제 #

  1. Avatar 컴포넌트를 만들고 src (필수), alt (선택, 기본값 ''), size (선택, 기본값 40) 세 prop을 받게 작성해 보세요. <Avatar src="/x.png" /><Avatar src="/x.png" size={64} /> 두 호출이 모두 컴파일 통과하는지 확인합니다.
  2. Cardchildren을 옵션으로 만들고 (PropsWithChildren), 호출 시 children을 빼 보세요. 컴파일 통과. 그 뒤 직접 children: ReactNode로 필수 선언하면 같은 호출이 빨간 줄로 막히는 것을 비교합니다.
  3. 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 안에서 자연스럽게 좁히는 패턴이 핵심입니다.

X