⚛️ 24. TypeScript × React 고급 패턴: 타입 안전한 컴포넌트 설계
📋 개요
제네릭 컴포넌트로 any 남용 탈출, Discriminated Union으로 불가능한 props 조합 차단, 다형성 컴포넌트의 완전한 타입 안전성까지. 실무 TypeScript 패턴을 코드로 체득합니다.
📋 목차
- 이 문서를 읽기 전에
- 왜 알아야 하는가: any 남용과 props 러시안 룰렛
- 비유로 먼저 이해하기
- 패턴 1: 제네릭 컴포넌트
- 패턴 2: Discriminated Union Props
- 패턴 3: 조건부 Props
- 패턴 4: ComponentProps로 네이티브 props 상속
- 패턴 5: forwardRef 타입 지정
- 패턴 6: 다형성 컴포넌트의 타입 안전성
- 에러 해결 카탈로그
- 이번에 배운 내용 총정리
- 마무리 퀴즈
- 더 알아보기
📌 이 문서를 읽기 전에
⏱️ 예상 읽기 시간: 18분 (전체) / 핵심 파트만: 10분
🗺️ 이 문서의 흐름
[any 남용 인식] → [제네릭 컴포넌트] → [Discriminated Union] → [조건부 Props] → [forwardRef] → [다형성 타입]
🎯 이 문서를 다 읽으면 할 수 있는 것
-
any없이 타입 안전한 제네릭 리스트/셀렉트 컴포넌트를 설계할 수 있다 - variant 에 따라 다른 props 를 요구하는 컴포넌트를 Discriminated Union 으로 설계할 수 있다
-
forwardRef에 올바른 타입을 지정할 수 있다
🗺️ 이 문서의 배경 세계관: '영수네 커뮤니티'
- 영철(신입): "공통
<Select>컴포넌트 만들었는데요,optionsprop을any[]로 받아서... TypeScript 쓰는 의미가 없어진 것 같고, 버튼 컴포넌트에href,icon,onClick을 다 optional로 받다 보니 잘못된 조합도 타입 에러 없이 통과해버려요." - 영호(리드): "영철 님,
any는 TypeScript 보험을 파기하는 거예요. 제네릭과 Discriminated Union 으로 컴파일 타임에 모든 잘못된 사용을 잡아낼 수 있어요."
🤔 왜 알아야 하는가: any 남용과 props 러시안 룰렛
🎯 이 섹션을 읽고 나면:
any남용이 왜 TypeScript를 무력화하는지 설명할 수 있다- optional props 남발이 왜 런타임 버그로 이어지는지 이해한다
any 늪 예시:
// ❌ any로 막아버린 Select 컴포넌트
interface SelectProps {
options: any[]; // ← any: 타입 정보 사라짐
onSelect: (v: any) => void; // ← any: 콜백 파라미터 타입 추론 불가
getLabel?: (option: any) => string;
}
function Select({ options, onSelect }: SelectProps) {
return (
<ul>
{options.map((option, i) => (
<li key={i} onClick={() => onSelect(option)}>
{option.name} {/* option이 any라 .name이 있는지 TypeScript가 모름 */}
</li>
))}
</ul>
);
}
// 사용: 타입 에러 없이 모든 게 통과됨
<Select options={[1, 2, 3]} onSelect={v => console.log(v.name)} />
// → 런타임 에러: number에는 .name이 없음! 하지만 TypeScript는 경고 없음optional props 러시안 룰렛:
// ❌ 모든 props를 optional로 → 잘못된 조합이 타입 에러 없이 통과
interface ButtonProps {
variant?: 'default' | 'link' | 'icon';
href?: string; // link variant에서만 필요한데 optional
icon?: ReactNode; // icon variant에서만 필요한데 optional
onClick?: () => void;
}
// 타입 에러 없이 통과하는 잘못된 조합들:
<Button variant="link" /> // href 없는 링크 버튼 → 런타임 에러
<Button variant="icon" href="/home" /> // icon 없는 아이콘 버튼 → 빈 버튼
<Button variant="default" href="/home" onClick={() => {}} /> // href + onClick 동시 → 의미 불명💡 한 줄로 기억하기
"any는 TypeScript 보험증권의 면책 조항이야.any가 있는 순간 그 타입 경로는 TypeScript의 보호를 받지 못해."
🏗️ 비유로 먼저 이해하기
🧒 5살에게 설명한다면?
레고 세트가 있다고 해봐. 영철이 방식은 "어떤 블록이든 어디든 끼울 수 있는 만능 구멍"(any)을 쓰는 거야. 모양이 안 맞아도 무리하게 끼울 수 있어. 나중에 탑이 무너지면 어디서 잘못됐는지 찾기 엄청 어려워.
TypeScript 제네릭은 "이 구멍은 파란 블록만, 저 구멍은 빨간 블록만" 처럼 형태가 정확히 맞아야 끼울 수 있는 특수 구멍이야. 모양이 맞지 않으면 끼우는 순간 바로 "이 블록은 여기 안 들어가!" 라고 알려줘.
🧩 패턴 1: 제네릭 컴포넌트 🟡
🎯 이 섹션을 읽고 나면:
any없이 타입 안전한 리스트/셀렉트 컴포넌트를 만들 수 있다- 제네릭 타입이 컴포넌트 props 전체에 흘러가는 원리를 이해한다
제네릭 Select 컴포넌트:
// ✅ 제네릭으로 타입 흐름 연결 — any 없이 완전한 타입 안전성
interface SelectProps<T> {
options: T[];
getLabel: (option: T) => string; // T에서 표시할 텍스트 추출 방법
getValue: (option: T) => string; // T에서 유니크 키 추출 방법
onSelect: (option: T) => void; // 선택됐을 때 — T 타입으로 전달됨
placeholder?: string;
}
// <T> 선언: 이 컴포넌트가 사용될 때 T가 결정됨
function Select<T>({ options, getLabel, getValue, onSelect, placeholder }: SelectProps<T>) {
return (
<ul>
{placeholder && <li style={{ color: 'gray' }}>{placeholder}</li>}
{options.map(option => (
<li
key={getValue(option)} // getValue로 키 추출
onClick={() => onSelect(option)} // onSelect에 T 타입 전달
>
{getLabel(option)} // getLabel로 표시 텍스트 추출
</li>
))}
</ul>
);
}사용: T가 자동으로 추론됨:
interface User {
id: string;
name: string;
email: string;
}
// T = User 로 자동 추론
<Select<User>
options={users}
getLabel={u => u.name} // u: User 타입으로 자동 추론
getValue={u => u.id} // u: User 타입
onSelect={user => {
console.log(user.email); // user: User 타입 → .email 안전하게 접근
}}
/>
// T = string 으로 자동 추론
<Select<string>
options={['영철', '영호', '영숙']}
getLabel={s => s} // s: string
getValue={s => s}
onSelect={s => console.log(s.toUpperCase())} // string 메서드 안전하게 사용
/>제네릭 List 컴포넌트 (renderItem 패턴):
// ✅ 렌더링 방식을 외부에서 주입받는 제네릭 리스트
interface ListProps<T> {
items: T[];
keyExtractor: (item: T) => string;
renderItem: (item: T, index: number) => React.ReactNode;
emptyMessage?: string;
}
function List<T>({ items, keyExtractor, renderItem, emptyMessage = '항목이 없어요.' }: ListProps<T>) {
if (items.length === 0) return <p>{emptyMessage}</p>;
return (
<ul>
{items.map((item, index) => (
<li key={keyExtractor(item)}>
{renderItem(item, index)} {/* 렌더링은 사용하는 쪽이 결정 */}
</li>
))}
</ul>
);
}
// 사용: Post 타입으로 추론, renderItem 내부에서 post.title 안전하게 접근
<List<Post>
items={posts}
keyExtractor={post => post.id}
renderItem={post => <PostCard title={post.title} author={post.author} />}
emptyMessage="게시글이 없어요."
/>💡 한 줄로 기억하기
"제네릭 컴포넌트는 '타입을 나중에 결정하는 틀'이야.any대신<T>를 쓰면 props 전체에 타입이 흘러 컴파일 타임에 오류를 잡아낼 수 있어."
🎭 패턴 2: Discriminated Union Props 🟡
🎯 이 섹션을 읽고 나면:
- variant 에 따라 필수 props 가 달라지는 컴포넌트를 타입 안전하게 설계할 수 있다
- 잘못된 props 조합을 컴파일 타임에 차단할 수 있다
Discriminated Union(판별 유니온) 은 type 필드(판별자)로 어떤 타입인지 결정하는 패턴이에요.
// ✅ Discriminated Union으로 variant별 props 타입 분리
type ButtonProps =
| {
variant: 'default';
children: React.ReactNode;
onClick?: () => void;
// href, icon 없음 — 이 variant에서는 불필요
}
| {
variant: 'link';
children: React.ReactNode;
href: string; // link variant에서 href는 필수!
target?: '_blank' | '_self';
}
| {
variant: 'icon';
icon: React.ReactNode; // icon variant에서 icon은 필수!
label: string; // 접근성: 스크린 리더용 레이블 필수
};
function Button(props: ButtonProps) {
// TypeScript가 각 case에서 props의 타입을 정확히 알고 있음
switch (props.variant) {
case 'default':
return (
<button onClick={props.onClick}>
{props.children}
{/* props.href 접근 → 타입 에러: 'default' variant에는 href 없음 */}
</button>
);
case 'link':
return (
<a href={props.href} target={props.target}>
{props.children}
{/* props.href는 반드시 존재 — TypeScript가 보장 */}
</a>
);
case 'icon':
return (
<button aria-label={props.label}>
{props.icon}
</button>
);
}
}컴파일 타임 오류 잡기:
// ✅ 올바른 사용
<Button variant="default" onClick={() => {}}>저장</Button>
<Button variant="link" href="/profile">프로필</Button>
<Button variant="icon" icon={<HeartIcon />} label="좋아요" />
// ❌ 컴파일 타임 에러 — 런타임 전에 잡힘!
<Button variant="link" /> // Error: href 없음
<Button variant="link" href="/home" icon={} /> // Error: link에는 icon 없음
<Button variant="icon" /> // Error: icon, label 없음Alert 컴포넌트 적용 예시:
type AlertProps =
| { type: 'info'; message: string }
| { type: 'success'; message: string; onDismiss?: () => void }
| { type: 'error'; message: string; onRetry: () => void }; // error엔 onRetry 필수
function Alert(props: AlertProps) {
const icons = { info: 'ℹ️', success: '✅', error: '❌' };
return (
<div className={`alert alert-${props.type}`}>
<span>{icons[props.type]}</span>
<p>{props.message}</p>
{props.type === 'success' && props.onDismiss && (
<button onClick={props.onDismiss}>닫기</button>
)}
{props.type === 'error' && (
<button onClick={props.onRetry}>다시 시도</button>
// props.onRetry: error variant에서 반드시 존재 — TypeScript 보장
)}
</div>
);
}💡 한 줄로 기억하기
"Discriminated Union 은 variant 별로 'props 계약서'를 따로 쓰는 거야. TypeScript가 각 계약서를 엄격하게 검사해서 잘못된 조합을 컴파일 전에 막아줘."
🔀 패턴 3: 조건부 Props 🔴
🎯 이 섹션을 읽고 나면:
- "A가 있으면 B도 반드시 있어야 한다" 패턴을 타입으로 표현할 수 있다
"label이 있으면 htmlFor도 반드시 있어야 해" 패턴:
// ❌ 둘 다 optional → 잘못된 조합도 통과
interface TextFieldProps {
label?: string;
htmlFor?: string; // label 있으면 htmlFor도 있어야 하는데 강제 불가
}
// ✅ 조건부 Props: 둘 다 있거나, 둘 다 없거나
type TextFieldProps =
| { label: string; htmlFor: string } // 둘 다 있음
| { label?: never; htmlFor?: never }; // 둘 다 없음 (never로 상호 배제)
// 사용
<TextField label="이메일" htmlFor="email-input" /> // ✅
<TextField /> // ✅ (둘 다 없음)
<TextField label="이메일" /> // ❌ htmlFor 없음 → 에러
<TextField htmlFor="email-input" /> // ❌ label 없음 → 에러XOR 타입 (A 또는 B, 둘 다는 불가):
// "onClick 또는 href 중 하나만" 패턴
type ExclusiveProps =
| { onClick: () => void; href?: never } // onClick만
| { href: string; onClick?: never }; // href만
function ActionButton({ onClick, href, children }: ExclusiveProps & { children: React.ReactNode }) {
if (href) return <a href={href}>{children}</a>;
return <button onClick={onClick}>{children}</button>;
}
// 사용
<ActionButton onClick={() => {}}>클릭</ActionButton> // ✅
<ActionButton href="/home">이동</ActionButton> // ✅
<ActionButton onClick={() => {}} href="/home">?</ActionButton> // ❌ 둘 다 → 에러💡 한 줄로 기억하기
"조건부 Props는 '이 prop이 있으면 저 prop도 반드시 있어야 해'를 타입으로 표현하는 것.never키워드가 상호 배제의 핵심이야."
🧰 패턴 4: ComponentProps로 네이티브 props 상속 🟡
🎯 이 섹션을 읽고 나면:
- 네이티브 HTML 요소의 모든 props 를 컴포넌트에 자동으로 상속할 수 있다
ComponentProps,ComponentPropsWithoutRef의 차이를 이해한다
컴포넌트가 내부에서 <input> 을 렌더링한다면, placeholder, maxLength, type, disabled 등 input의 모든 속성을 직접 정의하지 않아도 상속받을 수 있어요.
import { ComponentProps } from 'react';
// ✅ input의 모든 네이티브 props를 상속
interface TextFieldProps extends ComponentProps<'input'> {
label: string; // 커스텀 prop 추가
error?: string; // 커스텀 prop 추가
}
function TextField({ label, error, ...inputProps }: TextFieldProps) {
// ...inputProps 로 type, placeholder, disabled, maxLength 등 모두 전달
return (
<div>
<label>{label}</label>
<input
{...inputProps} // 네이티브 input props 전부 전달
className={`text-field ${error ? 'error' : ''}`}
/>
{error && <span className="error-msg">{error}</span>}
</div>
);
}
// 사용: 네이티브 input 속성 모두 자동완성 지원!
<TextField
label="이메일"
type="email" // input 네이티브 속성
placeholder="example@email.com" // input 네이티브 속성
maxLength={100} // input 네이티브 속성
required // input 네이티브 속성
error="유효한 이메일을 입력해주세요"
/>ComponentProps vs ComponentPropsWithoutRef:
// ComponentProps: ref 포함 (함수 컴포넌트에서는 안 씀)
// ComponentPropsWithoutRef: ref 제외 (일반적인 경우 이걸 써)
// ComponentPropsWithRef: ref 포함 (forwardRef 컴포넌트에 씀)
interface ButtonProps extends ComponentPropsWithoutRef<'button'> {
loading?: boolean;
}
// → button의 모든 props (onClick, type, disabled...) 상속 + loading 추가💡 한 줄로 기억하기
"extends ComponentProps<'input'>은 HTML input의 수십 가지 속성을 한 번에 상속받는 마법이야. 커스텀 컴포넌트가 네이티브처럼 동작하게 만들어줘."
🎯 패턴 5: forwardRef 타입 지정 🟡
🎯 이 섹션을 읽고 나면:
forwardRef에 올바른 타입을 지정할 수 있다- 부모 컴포넌트에서 자식 DOM 요소를
ref로 직접 제어할 수 있다
forwardRef 가 필요한 상황:
// ❌ ref가 전달 안 되는 일반 컴포넌트
function TextInput(props: TextFieldProps) {
return <input {...props} />;
}
// 부모에서 ref로 포커스 제어 시도 → 동작 안 함
const inputRef = useRef<HTMLInputElement>(null);
<TextInput ref={inputRef} /> // ❌ Warning: ref를 받지 못함forwardRef 로 ref 전달:
// ✅ forwardRef<DOM 타입, Props 타입>
const TextInput = forwardRef<HTMLInputElement, TextFieldProps>(
({ label, error, ...props }, ref) => { // 두 번째 인자로 ref 받음
return (
<div>
<label>{label}</label>
<input
ref={ref} // ref를 실제 input DOM에 연결
{...props}
/>
{error && <span>{error}</span>}
</div>
);
}
);
// DevTools에서 컴포넌트 이름 표시를 위해 displayName 설정
TextInput.displayName = 'TextInput';
// 사용: 부모에서 input DOM에 직접 접근 가능
function LoginForm() {
const emailRef = useRef<HTMLInputElement>(null);
const handleSubmit = () => {
emailRef.current?.focus(); // input DOM에 직접 포커스!
};
return (
<form onSubmit={handleSubmit}>
<TextInput ref={emailRef} label="이메일" type="email" />
</form>
);
}💡 한 줄로 기억하기
"forwardRef<DOM타입, Props타입>— 제네릭 첫 번째는 ref가 연결되는 DOM 요소 타입, 두 번째는 props 타입이야.displayName설정을 잊지 마."
🦎 패턴 6: 다형성 컴포넌트의 타입 안전성 🔴
🎯 이 섹션을 읽고 나면:
asprop으로 렌더링되는 HTML 태그를 변경하는 다형성 컴포넌트를 타입 안전하게 설계할 수 있다
20번 문서 에서 as prop 패턴을 배웠어요. 이번엔 TypeScript 타입까지 완전하게 만들어볼게요.
import { ElementType, ComponentPropsWithoutRef } from 'react';
// ✅ 다형성 컴포넌트 타입 어려움퍼
// C: 렌더링할 HTML 태그 또는 컴포넌트 타입
// OwnProps: 컴포넌트 고유 props
type PolymorphicProps<C extends ElementType, OwnProps = {}> = OwnProps & {
as?: C; // 어떤 태그로 렌더링할지
} & Omit<
ComponentPropsWithoutRef<C>, // C 태그의 모든 네이티브 props
keyof OwnProps | 'as' // 중복 제거
>;
// 실제 사용 예: Button 컴포넌트
type ButtonOwnProps = {
variant?: 'primary' | 'secondary' | 'ghost';
size?: 'sm' | 'md' | 'lg';
};
function Button<C extends ElementType = 'button'>({
as,
variant = 'primary',
size = 'md',
children,
...rest
}: PolymorphicProps<C, ButtonOwnProps> & { children?: React.ReactNode }) {
const Component = as || 'button';
return (
<Component
className={`btn btn-${variant} btn-${size}`}
{...rest}
>
{children}
</Component>
);
}타입 안전성 확인:
// ✅ button 태그로 렌더링 — button 속성 자동완성
<Button onClick={() => {}}>클릭</Button>
<Button type="submit" disabled>제출</Button>
// ✅ a 태그로 렌더링 — a 태그 속성이 자동완성됨
<Button as="a" href="/profile" target="_blank">프로필로 이동</Button>
// ✅ Next.js Link로 렌더링
<Button as={Link} href="/dashboard">대시보드</Button>
// ❌ 타입 에러: button 태그에 href는 없음
<Button href="/home">클릭</Button>
// ❌ 타입 에러: a 태그에 type="submit"은 의미 없음 (경고)
<Button as="a" type="submit">?</Button>💡 한 줄로 기억하기
"다형성 컴포넌트의 타입 안전성은ElementType제네릭 +ComponentPropsWithoutRef<C>조합으로 만들어.as="a"이면 a 태그 속성이,as="button"이면 button 속성이 자동으로 추론돼."
💥 에러 해결 카탈로그
❌ JSX element type 'Component' does not have any construct or call signatures
언제 나오는가?
function Button({ as: Component = 'button', ...props }) {
return <Component {...props} />; // 💥 타입 에러
}원인: as prop의 타입이 제대로 지정되지 않아서 TypeScript가 Component 를 JSX 요소로 렌더링할 수 있는지 모름.
해결책:
function Button<C extends ElementType = 'button'>({
as,
...props
}: { as?: C } & ComponentPropsWithoutRef<C>) {
const Component = as || 'button'; // ElementType으로 타입 확정
return <Component {...props} />;
}❌ Type 'string' is not assignable to type 'never'
언제 나오는가?
type Props = { href: string; onClick?: never } | { onClick: () => void; href?: never };
<Button href="/home" onClick={() => {}} /> // Error: Type 'string' is not assignable to 'never'원인: XOR 타입에서 두 prop을 동시에 넘길 수 없어요 — 의도된 오류!
해설: 이 오류는 버그를 잡아낸 거예요. href 와 onClick 을 동시에 넘기면 안 되는 설계를 TypeScript가 컴파일 타임에 막아준 것이에요.
❌ 제네릭 컴포넌트가 TypeScript에서 <T> 를 HTML 태그로 인식
언제 나오는가? .tsx 파일에서:
// ❌ TSX에서 <T>를 HTML 태그로 파싱할 수 있음
function Select<T>(props: SelectProps<T>) { ... } // 파싱 오류 가능해결책:
// 제약 조건 추가로 TSX 파서에게 이게 제네릭임을 알림
function Select<T extends object>(props: SelectProps<T>) { ... }
// 또는 trailing comma
function Select<T,>(props: SelectProps<T>) { ... }🏁 이번에 배운 내용 총정리
📋 TypeScript 패턴 6종 요약
| 패턴 | 문제 | 해결 |
|---|---|---|
| 제네릭 컴포넌트 | any[] 로 타입 소실 | <T> 제네릭으로 타입 흐름 유지 |
| Discriminated Union | optional props 조합 버그 | variant 별 독립 타입 계약 |
| 조건부 Props | A 없이 B만 쓰는 버그 | never 로 상호 배제 강제 |
| ComponentProps | 네이티브 props 직접 나열 | extends ComponentProps<'input'> |
| forwardRef | ref 전달 불가 | forwardRef<DOM, Props> |
| 다형성 타입 | as prop 타입 불안전 | ElementType + ComponentPropsWithoutRef<C> |
⚠️ 절대 하지 말 것
| ❌ 나쁜 예 | ✅ 좋은 예 | 이유 |
|---|---|---|
options: any[] | options: T[] | any는 타입 보호 파기 |
| 모든 props optional | Discriminated Union | 잘못된 조합 런타임 버그 |
@ts-ignore 남발 | 올바른 타입 정의 | 문제를 숨기는 것 |
| forwardRef 없이 ref 전달 | forwardRef<DOM, Props> | ref 미작동 |
📝 마무리 퀴즈
Q1. 아래 Discriminated Union 타입에서 컴파일 에러가 나는 사용은?
type CardProps =
| { variant: 'post'; title: string; content: string }
| { variant: 'user'; name: string; avatar: string }
| { variant: 'ad'; imageUrl: string; link: string };- A)
<Card variant="post" title="제목" content="내용" /> - B)
<Card variant="user" name="영철" avatar="/img.png" /> - C)
<Card variant="post" name="영철" /> - D)
<Card variant="ad" imageUrl="/ad.png" link="/offer" />
✅ 정답: C
variant="post"일 때는title과content가 필요한데,name만 있고title,content가 없어요. TypeScript가 컴파일 에러를 발생시켜요.📌 핵심 기억법: "Discriminated Union 은 variant 별 계약서야.
variant="post"를 선택했으면 post 계약서를 따라야 해."
Q2. 아래 빈칸을 채워보자.
// input의 모든 네이티브 props를 상속하는 TextField
interface TextFieldProps extends ________<'input'> {
label: string;
error?: string;
}
// ref를 전달받는 TextInput 컴포넌트
const TextInput = ________<HTMLInputElement, TextFieldProps>(
({ label, ...props }, ref) => <input ref={ref} {...props} />
);✅ 정답: 1번:
ComponentProps(또는ComponentPropsWithoutRef), 2번:forwardRef📌 핵심 기억법: "
ComponentProps는 네이티브 props 상속,forwardRef는 ref 전달. 둘 다 '컴포넌트를 네이티브처럼 동작하게' 만드는 패턴이야."
Q3. 영철이의 테스트 타임: 디자인 시스템의 ActionButton은 variant="link"일 때는 href가 반드시 필요하고 onClick은 없어야 합니다. variant="button"일 때는 onClick이 필요하고 href는 없어야 합니다. 영호가 타입 리뷰에서 승인할 설계는 무엇인가요?
- A)
href?: string,onClick?: () => void처럼 모든 props를 optional로 두고 런타임에서 조합을 검사한다. - B)
variant를 기준으로 Discriminated Union을 만들고, 허용되지 않는 props에는never를 사용해 잘못된 조합을 컴파일 타임에 막는다. - C)
ActionButtonProps = any로 두어 사용하는 쪽이 자유롭게 조합하게 한다.
✅ 정답: B
고급 TypeScript 패턴의 목적은 타입을 길게 쓰는 것이 아니라 잘못된 컴포넌트 계약을 호출부에서 바로 막는 것입니다. 모든 props를 optional로 열어두면
href없는 링크 버튼이나 링크와 클릭 핸들러가 섞인 의미 불명 버튼이 통과합니다.variant별 union과never를 쓰면 "이 모드에서는 이 props가 오면 안 된다"는 제품 규칙을 타입 시스템에 맡길 수 있습니다.
🐣 영철이의 퇴근 일기
TypeScript를 쓰면서도 any와 optional props로 대부분의 구멍을 열어두던 내 코드가 떠올랐다. 오늘은 타입이 문법 장식이 아니라 컴포넌트의 사용 계약을 문서화하고, 잘못된 조합을 리뷰 전에 막아주는 장치라는 걸 배웠다.
💡 "좋은 React 타입은 props를 많이 허용하는 타입이 아니라, 제품에서 말이 안 되는 조합을 호출부에서 막는 타입이다."
영호 님이 variant별 props를 표로 먼저 그려보라고 한 게 도움이 됐다. 내일부터 공통 컴포넌트를 만들 때는 필수 props, 서로 같이 올 수 없는 props, 네이티브 props 상속, ref 전달 여부를 먼저 정리하겠다. 이제 영철은 타입 에러를 없애는 사람이 아니라, 타입 에러가 제품 규칙을 대신 지키게 만드는 사람이 되고 싶다.
🔗 더 알아보기
- React TypeScript Cheatsheet — 실무 패턴 총망라
- TypeScript Handbook — Generics
- 20번 문서 — 다형성과 접근성 —
asprop 기본 패턴 - Matt Pocock의 TypeScript 강좌 — 실무 TypeScript 패턴