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

14 분 소요

#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들의 타입을 어떻게 잡고 어디까지 추론에 맡길지를 다룹니다.