타입스크립트 + React 실전 #2 props와 children 타이핑
#1 시작과 셋업에서 첫 컴포넌트에 props 타입을 달아봤습니다. 이번 글에서는 props 타이핑의 실전 결정들을 다룹니다. 어디까지 좁히고, 언제 union으로 분기하고, children은 어떻게 받을지까지 짚습니다.
type vs interface — 둘 중 무엇을 써야 할까 #
기초 강좌 #3 interface와 type alias에서 차이를 정리했지만, 리액트 컴포넌트 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를 권합니다.
children 타이핑 — ReactNode를 기본으로
#
리액트 컴포넌트가 자식 엘리먼트를 받는 패턴은 매우 흔합니다. children에 어떤 타입을 줘야 합니까?
대부분의 경우 React.ReactNode 입니다. 문자열, 숫자, 엘리먼트, 배열, null, 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>종종 더 좁게 잡고 싶을 때가 있는데, 함수 형태의 children(render prop) 이나 특정 컴포넌트만 받는 children 입니다.
1) render prop children이 함수일 때는 그 함수의 시그니처를 적어줍니다.
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으로 받고 컴포넌트가 직접 렌더하는 게 깔끔합니다.
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을 선택으로 만듭니다. children이 없는 호출도 허용하고 싶으면 이쪽이 편합니다.
명시적으로 적어도 무방합니다 — 두 패턴 모두 흔하니 팀 컨벤션에 맞추세요.
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를 붙여 두면 그런 실수가 컴파일 단계에서 잡힙니다.
마무리 #
이번 글에서는 다음을 정리했습니다.
- 컴포넌트 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들의 타입을 어떻게 잡고 어디까지 추론에 맡길지를 다루겠습니다.