타입스크립트 + React 실전 #2 props와 children 타이핑

#1 시작과 셋업에서 첫 컴포넌트에 props 타입을 달아봤습니다. 이번 글에서는 props 타이핑의 실전 결정들을 다룹니다. 어디까지 좁히고, 언제 union으로 분기하고, children은 어떻게 받을지까지 짚습니다.

type vs interface — 둘 중 무엇을 써야 할까 #

기초 강좌 #3 interface와 type alias에서 차이를 정리했지만, 리액트 컴포넌트 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를 권합니다.

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

리액트 컴포넌트가 자식 엘리먼트를 받는 패턴은 매우 흔합니다. children에 어떤 타입을 줘야 합니까?

대부분의 경우 React.ReactNode 입니다. 문자열, 숫자, 엘리먼트, 배열, null, undefined까지 리액트가 렌더할 수 있는 모든 것을 포함합니다.

가장 흔한 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 입니다.

1) 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)로 넘기는 구현 (#3, #6에서 자세히)
  return null;
}

제네릭은 #5 Context와 제네릭 컴포넌트에서 본격적으로 다룹니다. 지금은 “children 타입은 () => ReactNode 같은 함수도 될 수 있다"만 기억해 두세요.

2) 특정 엘리먼트만 받는 children — 거의 권하지 않음<List>의 children은 <ListItem>만 받게 해 줘” 같은 요구는 자주 들리지만, 타입스크립트의 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을 선택으로 만듭니다. children이 없는 호출도 허용하고 싶으면 이쪽이 편합니다.

명시적으로 적어도 무방합니다 — 두 패턴 모두 흔하니 팀 컨벤션에 맞추세요.

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를 붙여 두면 그런 실수가 컴파일 단계에서 잡힙니다.

마무리 #

이번 글에서는 다음을 정리했습니다.

  • 컴포넌트 props는 type으로 쓴다
  • ?로 선택 prop, 디스트럭처링 시점에 기본값
  • HTML 속성을 그대로 받으려면 ComponentProps<'button'>
  • 상호 배타 props는 discriminated union (as: 'button' | 'a')
  • children은 React.ReactNode가 기본, 함수 children은 시그니처를 적기
  • PropsWithChildren은 children을 optional로 추가하는 헬퍼
  • 다른 컴포넌트의 props 합성은 ComponentProps<typeof X>
  • 수정하지 않을 배열/객체 props는 readonly

다음 글(#3 hooks 타이핑)에서는 useState, useReducer, useRef, useCallback, useMemo 같은 빌트인 hook들의 타입을 어떻게 잡고 어디까지 추론에 맡길지를 다루겠습니다.

X