고급 타입 패턴과 Best Practices
조건부 props·유틸리티·제네릭 컴포넌트·판별 유니온·satisfies를 조합해 잘못된 UI 상태를 타입으로 차단합니다.
React와 타입스크립트를 함께 쓰다 보면 기본 Props/State 타입 정의만으로는 부족한 순간이 옵니다.
특히 복잡한 컴포넌트 구조나 재사용 유틸리티 컴포넌트를 만들 때는 고급 타입 패턴을 활용해야 유연성과 타입 안전성을 함께 얻을 수 있습니다.
이 절에서는 조건부 타입, 유틸리티 타입, 제네릭 컴포넌트 같은 고급 패턴을 React 맥락에서 살펴봅니다.
유니온과 never를 이용한 Props 제어
이 절에서는 서로 다른 Props 계약을 유니온 타입으로 묶고, 함께 사용할 수 없는 속성 조합을 선택적 never로 표현합니다.
React 컴포넌트에서 링크용 Props와 버튼용 Props처럼 서로 다른 사용 방식을 구분할 때 유용합니다.
예시: 버튼 컴포넌트의 조건부 Props버튼 컴포넌트가 href Prop을 받으면 <a> 태그로 렌더링되고, onClick Prop을 받으면 <button> 태그로 렌더링된다고 가정해봅시다.
import React from 'react';
// 공통 Props 인터페이스
interface BaseButtonProps {
children: React.ReactNode;
className?: string;
disabled?: boolean;
}
// <a> 태그에 필요한 Props
interface AnchorButtonProps extends BaseButtonProps {
href: string;
onClick?: never; // href가 있으면 onClick은 허용하지 않음
}
// <button> 태그에 필요한 Props
interface HtmlButtonProps extends BaseButtonProps {
onClick: (event: React.MouseEvent<HTMLButtonElement>) => void;
href?: never; // onClick이 있으면 href는 허용하지 않음
}
// 유니온으로 최종 ButtonProps 정의
// 선택적 never로 href와 onClick의 동시 전달을 금지
type ButtonProps = AnchorButtonProps | HtmlButtonProps;
const ConditionalButton: React.FC<ButtonProps> = ({ children, className, disabled, href, onClick }) => {
if (href) {
// href가 제공되면 <a> 태그로 렌더링
return (
<a href={href} className={className} style={disabled ? { opacity: 0.5, pointerEvents: 'none' } : {}}>
{children}
</a>
);
} else {
// onClick이 제공되거나 href가 없으면 <button> 태그로 렌더링
return (
<button
onClick={onClick as (event: React.MouseEvent<HTMLButtonElement>) => void} // onClick이 반드시 있을 때만 여기로 오도록 타입 단언
className={className}
disabled={disabled}
>
{children}
</button>
);
}
};
export default ConditionalButton;
// 사용 예시
const AppWithConditionalButton: React.FC = () => {
const handleClick = () => alert('Button Clicked!');
return (
<div>
<h3>Conditional Button Examples</h3>
{/* <a> 태그로 렌더링됨 */}
<ConditionalButton href="https://google.com" className="link-btn">
Go to Google
</ConditionalButton>
{/* <button> 태그로 렌더링됨 */}
<ConditionalButton onClick={handleClick} className="html-btn">
Click Me
</ConditionalButton>
{/*
// 컴파일 에러: href와 onClick을 동시에 제공
<ConditionalButton href="/home" onClick={handleClick}>
Error Button
</ConditionalButton>
// Type '{ children: string; href: string; onClick: () => void; }'
// is not assignable to type 'ButtonProps'.
// Type '{ children: string; href: string; onClick: () => void; }'
// is not assignable to type 'AnchorButtonProps'.
// Types of property 'onClick' are incompatible.
// Type '() => void' is not assignable to type 'undefined'.
// 컴파일 에러: 유니온의 두 경우 모두 href 또는 onClick을 요구함
// 이 예제의 HtmlButtonProps는 onClick을 필수로 선언함
// 또는 href?: string; onClick?: () => void; 로 하고 둘 다 없어도 허용할지 결정
// 여기서는 onClick/href 중 하나는 필수라는 가정을 위해 위와 같이 정의함
*/}
</div>
);
};이 예제는 T extends U ? X : Y 형태의 조건부 타입이 아니라 유니온과 선택적 never로 금지 조합을 표현합니다. href=""는 타입상 문자열이지만 현재 truthy 검사에서는 버튼 분기로 가므로, 존재 여부와 참인 값을 구분해야 합니다. 링크의 CSS pointerEvents만으로 키보드 접근까지 비활성화되는 것도 아닙니다.
여기서 onClick?: never와 href?: never는 반대쪽의 실제 값을 금지합니다. exactOptionalPropertyTypes를 켜면 해당 선택 속성에 undefined를 명시하는 것까지 금지합니다. 이 옵션을 끈 설정에서는 onClick: undefined처럼 키는 있으나 값은 undefined인 형태가 허용될 수 있으므로, 키 존재 자체를 모든 설정에서 금지한다고 읽지 않습니다.
이렇게 하면 타입스크립트가 href와 onClick이 동시에 존재하는 경우를 감지하여 컴파일 에러를 발생시킵니다.
빈 링크 값과 실행 분기를 대조하기
원 링크 예제의 href를 빈 문자열로 바꾸면 타입은 통과하지만 if (href)는 거짓입니다. 원 코드를 보존한 복사본에서 이 조건을 href !== undefined로 바꾸어, 문자열의 내용이 비어 있는 것과 링크 속성 자체가 없는 것을 구별하세요. 빈 href는 현재 문서를 가리키는 링크이므로, 업무상 허용하지 않으려면 별도 입력 정책이 필요합니다. 이 한 줄 수정이 키보드까지 포함한 disabled 링크 정책을 완성하는 것은 아닙니다.
각 컴포넌트 예제는 별도 .tsx 파일입니다. AppWithConditionalButton과 AppWithGenericList는 원 파일 안에 선언만 되어 있으므로, 비교 화면을 보려면 복사본에서 해당 App을 내보내고 루트 App에서 사용합니다. 원 기본 내보내기가 곧 비교 화면 전체를 뜻하지는 않습니다.
유틸리티 타입 활용
타입스크립트는 타입 변환 및 조작을 위한 다양한 유틸리티 타입을 내장하고 있습니다.
이를 React 컴포넌트의 Props나 State를 정의할 때 활용하면 코드의 중복을 줄이고 유연성을 높일 수 있습니다.
Partial<T>: 모든 속성을 선택적으로
기존 인터페이스의 모든 속성을 선택적(optional)으로 만들 때 사용합니다.
interface Product {
id: number;
name: string;
price: number;
category: string;
}
// Product의 모든 속성을 선택적으로 만듭니다.
type PartialProduct = Partial<Product>;
const updateProduct = (productId: number, changes: PartialProduct) => {
// ... 제품 업데이트 로직 (부분 업데이트 가능)
console.log(`Updating product ${productId} with changes:`, changes);
};
// 사용 예시
updateProduct(1, { price: 1250 }); // name이나 category 없이 price만 변경 가능
updateProduct(2, { name: 'Wireless Keyboard', category: 'Accessories' });
// updateProduct(3, { id: 3 }); // id는 Product에서만 필요하고 PartialProduct에서는 optional이다.이것은 예를 들어, initialValues를 Partial<FormValues>로 정의하여 폼의 초기값이 모든 필드를 포함할 필요가 없을 때 유용합니다.
PartialProduct는 id까지 선택 속성으로 만들므로 id의 변경 금지 정책을 표현하지 않습니다. 아래처럼 수정 가능한 필드만 먼저 고르면, 새 객체 리터럴에 id를 넣었을 때 타입 검사가 막습니다. 그래도 외부 객체의 필드를 런타임에 삭제하거나 검증해 주지는 않습니다. 원 updateProduct는 console.log만 실행하며 실제 저장소를 갱신하지 않습니다.
type ProductPatch = Partial<Pick<Product, 'name' | 'price' | 'category'>>;
function updateAllowedProduct(productId: number, changes: ProductPatch) {
console.log(`Updating product ${productId} with changes:`, changes);
}
updateAllowedProduct(1, { price: 1250 });
// updateAllowedProduct(1, { id: 2 }); // 새 객체 리터럴의 id는 계약에 없음Pick<T, K>: 특정 속성만 선택하기
T 타입에서 K에 해당하는 속성들만 골라 새로운 타입을 만들 때 사용합니다.
interface UserProfile {
id: number;
name: string;
email: string;
address: string;
phone: string;
}
// UserProfile에서 'name'과 'email'만 선택
type UserContactInfo = Pick<UserProfile, 'name' | 'email'>;
interface ContactCardProps {
user: UserContactInfo;
}
const ContactCard: React.FC<ContactCardProps> = ({ user }) => {
return (
<div>
<h3>{user.name}</h3>
<p>Email: {user.email}</p>
</div>
);
};
// 사용 예시
const currentUser: UserProfile = {
id: 1,
name: 'John Doe',
email: '[email protected]',
address: '123 Main St',
phone: '555-1234'
};
// currentUser를 그대로 전달할 수 있습니다. 타입스크립트가 자동으로 Pick된 타입을 만족하는지 검사합니다.
<ContactCard user={currentUser} />;
// 혹은 명시적으로 Pick된 타입을 생성하여 전달할 수도 있습니다.
const pickedUser: UserContactInfo = { name: currentUser.name, email: currentUser.email };
<ContactCard user={pickedUser} />;Pick은 컴포넌트가 부모로부터 불필요한 모든 정보를 받는 대신, 필요한 정보만 명확히 받도록 인터페이스를 정의할 때 유용합니다.
원 currentUser를 그대로 전달하면 런타임 객체에는 id·name·email·address·phone 다섯 키가 남습니다. ContactCard가 읽는 두 필드만 타입으로 요구할 뿐입니다. 명시적으로 새 객체를 만든 pickedUser에는 name과 email 두 키만 있습니다. 화면에 두 값만 보이는 것과 전달 데이터에서 나머지 값을 제거하는 것은 서로 다른 작업입니다.
Omit<T, K>: 특정 속성 제외하기
T 타입에서 K에 해당하는 속성들을 제외한 새로운 타입을 만들 때 사용합니다.
interface Post {
id: number;
title: string;
content: string;
authorId: number;
createdAt: Date;
}
// Post에서 'id'와 'createdAt'을 제외한 타입을 생성합니다.
type EditablePost = Omit<Post, 'id' | 'createdAt' | 'authorId'>;
interface PostEditorProps {
initialPost?: EditablePost;
onSave: (post: EditablePost) => void;
}
const PostEditor: React.FC<PostEditorProps> = ({ initialPost, onSave }) => {
const [title, setTitle] = useState(initialPost?.title || '');
const [content, setContent] = useState(initialPost?.content || '');
const handleSubmit = (event: React.FormEvent) => {
event.preventDefault();
onSave({ title, content }); // 저장 시 ID나 생성일시는 포함되지 않습니다.
};
return (
<form onSubmit={handleSubmit}>
<input type="text" value={title} onChange={e => setTitle(e.target.value)} placeholder="Title" />
<textarea value={content} onChange={e => setContent(e.target.value)} placeholder="Content"></textarea>
<button type="submit">Save Post</button>
</form>
);
};Omit은 타입에서 일부 속성을 제외합니다. 실제 객체에서 필드를 지우거나 외부 응답을 검증하지는 않습니다. Pick과 Partial도 정적 타입 변환이며, PostEditor의 useState는 사용할 파일에서 별도로 가져와야 합니다.
PostEditor 복사 파일의 맨 위에는 import { useState } from 'react';를 추가합니다. EditablePost에서 제외한 필드는 id·createdAt·authorId 세 개이며, 저장 콜백에 새로 만든 객체는 title과 content 두 키만 갖습니다. initialPost를 나중에 바꾸어도 useState의 최초 초기값이 자동으로 다시 적용되는 것은 아닙니다. 다른 게시글을 편집하도록 전환하는 정책은 상태 재설정이나 컴포넌트 식별자와 함께 따로 설계해야 합니다.
제네릭 컴포넌트
제네릭 컴포넌트는 다양한 타입의 데이터를 다룰 수 있도록 일반화된(generic) 방식으로 작성된 컴포넌트입니다.
이는 여러 타입의 데이터를 보여주거나 처리하는 범용적인 컴포넌트를 만들 때 매우 유용합니다.
예시: 제네릭 리스트 컴포넌트import React from 'react';
interface Item {
id: string | number;
[key: string]: any; // 다른 속성들도 허용
}
interface GenericListProps<T extends Item> { // T는 Item 타입을 확장해야 함
items: T[];
// 각 아이템을 렌더링하는 함수 (렌더 Props 패턴과 유사)
renderItem: (item: T) => React.ReactNode;
// 각 아이템의 고유 키를 반환하는 함수 (선택적)
getKey?: (item: T) => React.Key;
}
// 제네릭 함수형 컴포넌트 정의
function GenericList<T extends Item>({ items, renderItem, getKey }: GenericListProps<T>) {
return (
<ul>
{items.map((item) => (
<li key={getKey ? getKey(item) : item.id}>
{renderItem(item)}
</li>
))}
</ul>
);
}
export default GenericList;
// 사용 예시
interface User {
id: number;
name: string;
age: number;
}
interface Product {
id: string;
productName: string;
price: number;
}
const AppWithGenericList: React.FC = () => {
const users: User[] = [
{ id: 1, name: 'Alice', age: 30 },
{ id: 2, name: 'Bob', age: 24 },
];
const products: Product[] = [
{ id: 'p1', productName: 'Laptop', price: 1200 },
{ id: 'p2', productName: 'Mouse', price: 25 },
];
return (
<div>
<h3>User List:</h3>
<GenericList
items={users}
renderItem={(user) => (
<div>
<strong>{user.name}</strong> ({user.age} years old)
</div>
)}
getKey={(user) => user.id} // 숫자 id도 React.Key로 사용 가능
/>
<h3>Product List:</h3>
<GenericList
items={products}
renderItem={(product) => (
<div>
{product.productName} - ${product.price}
</div>
)}
// getKey는 생략해도 id 속성이 있기 때문에 기본적으로 item.id를 키로 사용
/>
</div>
);
};GenericList 컴포넌트는 T extends Item 제네릭을 사용하여 items 배열의 요소 타입과 renderItem 함수의 인자 타입을 동적으로 지정할 수 있습니다.
이렇게 하면 User 배열이든 Product 배열이든 동일한 GenericList 컴포넌트를 재사용할 수 있으며, 타입스크립트가 각 아이템의 속성에 대한 정확한 타입 검사를 수행합니다.
원 사용자 목록은 Alice (30 years old), Bob (24 years old)를 표시하고 상품 목록은 Laptop - $1200, Mouse - $25를 표시합니다. 같은 GenericList라도 T에 따라 renderItem의 인수가 달라집니다. 사용자 콜백에서 productName을 읽으려 하면 타입 검사가 막아야 합니다.
getKey를 생략하면 item.id를 쓰지만, id 타입이 string 또는 number라는 조건은 서로 다른 항목의 키가 고유하다는 조건과 같지 않습니다. 서로 다른 두 사용자에 id 1을 주면 그 값들은 타입에 맞으면서도 목록의 식별 정책을 어깁니다. 중복을 검사하거나 데이터 생성 규칙을 유지하는 책임은 별도로 남습니다.
타입 가드를 활용한 런타임 타입 검사
타입스크립트는 컴파일 시점에 타입을 검사하지만, 런타임에는 타입 정보가 사라집니다.
그러나 타입 가드를 사용하면 런타임에 특정 조건에 따라 변수의 타입을 좁힐 수 있으며, 이는 조건부 렌더링이나 데이터 처리 로직에서 유용합니다.
예시: 데이터 타입에 따른 렌더링import React from 'react';
interface TextContent {
type: 'text';
value: string;
}
interface ImageContent {
type: 'image';
src: string;
alt: string;
}
type Content = TextContent | ImageContent;
// 타입 가드 함수
function isImageContent(content: Content): content is ImageContent {
return content.type === 'image';
}
interface ContentDisplayProps {
content: Content;
}
const ContentDisplay: React.FC<ContentDisplayProps> = ({ content }) => {
if (isImageContent(content)) {
// 이 블록 안에서 content는 ImageContent 타입으로 자동 추론됩니다.
return <img src={content.src} alt={content.alt} style={{ maxWidth: '100%', height: 'auto' }} />;
} else {
// 이 블록 안에서 content는 TextContent 타입으로 자동 추론됩니다.
return <p>{content.value}</p>;
}
};
// 사용 예시
const AppWithContentDisplay: React.FC = () => {
const textItem: TextContent = { type: 'text', value: 'This is a text content.' };
const imageItem: ImageContent = {
type: 'image',
src: '/docs/common/fallback-image.svg',
alt: 'Sample Image'
};
return (
<div>
<h3>Content Display Examples</h3>
<ContentDisplay content={textItem} />
<ContentDisplay content={imageItem} />
</div>
);
};
export default AppWithContentDisplay;isImageContent 함수는 content의 type 속성을 확인하여 content가 ImageContent 타입인지 여부를 런타임에 판단하고, 타입스크립트 컴파일러에게 해당 블록 내에서 content의 타입을 좁히도록 지시합니다.
이 가드는 이미 Content로 표현된 값의 tag를 좁힙니다. 외부 unknown을 검증하려면 객체 여부와 src·alt 같은 필드의 값 타입도 검사해야 합니다.
예시
외부 값을 받을 때는 ContentDisplay에 넣기 전에 unknown부터 검사합니다. 다음 함수는 객체의 형태와 필요한 문자열 필드를 확인합니다. URL의 안전성, 이미지가 실제로 존재하는지, 응답 상태까지 확인하는 검증은 아닙니다. 추가 필드는 허용합니다.
function isContent(value: unknown): value is Content {
if (typeof value !== 'object' || value === null || Array.isArray(value)) return false;
const record = value as Record<string, unknown>;
if (record.type === 'text') return typeof record.value === 'string';
if (record.type === 'image') {
return typeof record.src === 'string' && typeof record.alt === 'string';
}
return false;
}{ type: 'image', src: 42, alt: '사진' }은 tag만 검사하면 image로 분류되지만, 이 함수에서는 src가 문자열이 아니므로 거절합니다. 여기의 타입 단언은 객체의 필드를 검사하기 위한 접근 형태만 제공하며, 실제 참·거짓은 뒤의 조건이 결정합니다.
Discriminated Union과 never를 이용한 Exhaustive Checking
그림은 아래 원문의 idle·loading·success·error를 반환 문자열에 대응시킵니다. 빈 배열도 success 안에서 처리합니다. 보조 카드의 별도 empty 상태는 확장 선택이며, 적용하려면 유니온·전이·렌더 분기를 함께 갱신합니다.
DISCRIMINATED UNION / EXHAUSTIVE SWITCH
status별 데이터와 렌더 분기를 같은 계약으로 맞춘다
본문 UserLoadState의 idle·loading·success·error가 각각 switch 분기로 이어집니다. success에는 빈 users 배열도 들어갈 수 있습니다. 새 variant를 추가하면 default의 assertNever에서 누락을 정적으로 확인합니다.
각 행은 상태 → 렌더 결과; 시간 순서가 아니다
각 쌍: 상태 → 반환값서로 시간 전이가 아님
STATE CONTRACT
각 상태에 필요한 데이터
success에서 users를, error에서 message를 읽도록 status로 좁힙니다. 서로 모순되는 boolean 조합을 줄이며 전이 자체를 자동 구현하거나 외부 응답을 검증하지는 않습니다.
LESSON OUTPUT
네 status와 반환 문자열
idle은 “사용자 조회 전”, loading은 “불러오는 중...”, success는 users.length를 이용한 “사용자 N명”, error는 message를 포함한 “오류: ...”를 반환합니다. 각 행은 시간 전이가 아닌 상태와 결과의 대응입니다.
STATIC FEEDBACK
새 상태를 추가하면 분기도 갱신
strict 기준으로 모든 case를 처리하면 default의 state가 never로 좁혀집니다. 새 variant를 처리하지 않으면 assertNever(state) 인자가 never에 맞지 않아 컴파일 진단을 얻습니다.
MODEL EXTENSION
empty 상태를 나누는 선택
원문 success는 빈 배열도 포함합니다. 별도 empty variant를 추가하는 확장은 0명 화면을 따로 표현하지만, 그 경우 union·전이·switch를 함께 갱신해야 합니다.
RUNTIME LIMIT
assertNever가 검증기는 아니다
함수의 throw는 예상 못한 값이 실제 들어올 때 실행됩니다. 타입 단언으로 우회한 외부 unknown을 정적으로 검사한 union과 같다고 가정하지 않습니다.
본문 renderState는 문자열을 반환합니다. JSX 분기로 확장할 수 있지만 assertNever는 정상 렌더 뒤에 실행되는 처리 단계가 아닙니다.
복잡한 UI 상태(로딩/성공/실패)를 문자열 플래그 여러 개로 분리하면, 서로 모순되는 상태(isLoading=true인데 data도 존재)가 쉽게 만들어집니다.
이때 Discriminated Union으로 상태를 하나의 유니온으로 모델링하면 안전성이 크게 올라갑니다.
// tsconfig.json: "strict": true 기준
type UserLoadState =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; users: Array<{ id: number; name: string }> }
| { status: 'error'; message: string };
function assertNever(value: never): never {
throw new Error(`Unhandled state: ${JSON.stringify(value)}`);
}
function renderState(state: UserLoadState): string {
switch (state.status) {
case 'idle':
return '사용자 조회 전';
case 'loading':
return '불러오는 중...';
case 'success':
return `사용자 ${state.users.length}명`;
case 'error':
return `오류: ${state.message}`;
default:
return assertNever(state); // 새 상태를 추가했는데 분기 누락 시 컴파일 에러 유도
}
}assertNever 패턴은 모든 분기를 처리했는지를 컴파일 타임에 강제합니다.
기능 확장 시 분기 누락을 바로잡을 수 있어 유지보수성이 높아집니다.
satisfies와 as const를 함께 쓰는 설정 객체 패턴
실무에서는 설정 객체(routes, featureFlags, role-policy)를 코드 전역에서 재사용합니다.
이때 as const로 리터럴 타입을 보존하고, satisfies로 구조 검증을 걸면 타입 안정성과 자동 완성 품질을 동시에 확보할 수 있습니다.
// tsconfig.json: "strict": true 기준
type RouteConfig = {
path: `/${string}`;
auth: 'public' | 'private';
};
const routes = {
home: { path: '/', auth: 'public' },
dashboard: { path: '/dashboard', auth: 'private' },
settings: { path: '/settings', auth: 'private' },
} as const satisfies Record<string, RouteConfig>;
type RouteKey = keyof typeof routes; // "home" | "dashboard" | "settings"
function getRoutePath(key: RouteKey): string {
return routes[key].path;
}as const: 값이string으로 넓어지지 않고'/','/dashboard'같은 리터럴 타입으로 유지됩니다.satisfies: 객체의 실제 값 타입은 보존하면서, 지정한 계약(Record<string, RouteConfig>)을 충족하는지 검증합니다.
타입 계약과 실제 값을 나누는 독립 연습
다음은 각각 앞 예제의 독립 입력입니다. ① exactOptionalPropertyTypes를 켠 경우와 끈 경우에 링크 Props의 onClick: undefined가 어떻게 다른가요? 실제 함수 onClick까지 함께 넣는 것은 어떤가요? ② price만 바꾸는 ProductPatch와 id를 넣은 새 리터럴 중 무엇이 통과하나요? currentUser를 그대로 전달한 객체와 pickedUser의 키 수는 각각 몇 개인가요? ③ isContent에 null, { type: 'text', value: 'hello' }, { type: 'image', src: 42, alt: '사진' }을 전달한 결과를 적으세요.
별도로 renderState({ status: 'success', users: [] })와 renderState({ status: 'error', message: 'timeout' })의 반환값을 적습니다. empty 상태를 유니온에만 추가하고 switch는 그대로 두면 어느 검사에서 빠진 분기를 발견해야 하나요? getRoutePath('reports')는 통과할까요? 설정의 private 문자열을 선언했다는 이유만으로 접근 권한 검사가 실행된다고 설명할 수 있을까요?
옵션·필드·분기·업무 경계를 나눈 풀이
onClick: undefined는 옵션을 켰을 때 금지되고 끈 설정에서는 허용될 수 있습니다. 실제 onClick 함수와 href 문자열의 동시 전달은 양쪽 계약 모두에 맞지 않습니다. ProductPatch의 price는 통과하고 새 리터럴의 id는 허용 필드가 아닙니다. currentUser는 다섯 키를 그대로 가진 객체이고 pickedUser는 새로 만든 두 키 객체입니다. 타입 변환 자체가 값을 지운 것은 아닙니다.
세 isContent 결과는 false, true, false입니다. 마지막 입력은 image라는 tag가 맞아도 src의 값 타입이 잘못됐습니다. 원 isImageContent는 Content라는 입력 계약을 이미 만족한 값의 종류를 구분하는 함수이므로 외부 입력의 완전한 검사로 대체할 수 없습니다.
빈 성공 목록은 사용자 0명, 실패 상태는 오류: timeout입니다. empty를 추가한 뒤 원 switch를 유지하면 default에서 state가 never가 아니므로 assertNever(state)가 타입 오류를 내야 합니다. 원 코드는 아직 empty 상태를 가진 유니온이 아닙니다. reports는 RouteKey에 없고 슬래시로 시작하지 않는 path도 RouteConfig에 맞지 않습니다.
as const는 타입 수준의 readonly와 리터럴 추론을 제공하며 Object.freeze를 호출하지 않습니다. satisfies도 런타임 검증 코드가 아닙니다. private는 접근 정책을 표시한 설정 값이고, 실제 인증·인가 판단과 라우터 연결은 별도 구현이 필요합니다.
선택 속성의 undefined 정책은 exactOptionalPropertyTypes 공식 설명, 구조 검사와 추론의 구별은 satisfies 도입 설명에 연결됩니다.
고급 타입 패턴 정리
React 애플리케이션에서 타입스크립트의 고급 타입 패턴을 활용하는 것은 코드의 견고성, 유연성, 그리고 유지보수성을 한 단계 더 끌어올립니다.
- 유니온과
never는 Props의 금지 조합을 표현합니다. - 유틸리티 타입 (
Partial,Pick,Omit등)은 기존 타입을 재활용하고 변형하여 Props와 State 정의의 중복을 줄이고 명확성을 더합니다. - 제네릭 컴포넌트는 다양한 데이터 타입에 대응할 수 있는 재사용 가능한 컴포넌트를 만드는 데 필수적입니다.
- 타입 가드는 런타임에 타입을 안전하게 좁히고 조건부 로직을 작성하는 데 도움을 줍니다.
- Discriminated Union +
never검사는 상태 분기 누락을 컴파일 타임에 방지합니다. satisfies+as const조합은 설정 객체의 리터럴 정확도와 구조 검증을 동시에 보장합니다.
고급 타입 패턴은 많이 쓰는 것보다 어떤 문제를 타입으로 막을지를 먼저 정하는 것이 중요합니다.
UI TYPE PATTERNS / FAILURE AND CONTRACT
UI에서 막을 실패에 맞는 타입 패턴을 고른다
금지 Props 조합, shape 재사용, 입력과 callback의 관계, 외부 데이터 검증, 상태 분기, 설정 객체는 서로 다른 문제입니다. 본문 예제로 계약의 효과와 runtime 한계를 함께 확인합니다.
FORBIDDEN COMBINATION
union과 선택적 never
ButtonProps는 href와 onClick의 동시 전달을 금지합니다. T extends U ? X : Y 형태의 조건부 타입 예제가 아니며 href=""의 truthy 분기와 링크 키보드 비활성화는 별도의 실제 동작 문제입니다.
STATIC SHAPE
Pick·Omit·Partial
필드를 고르거나 제외하거나 선택적으로 만드는 정적 타입 변환입니다. 실제 객체의 필드를 삭제하지 않습니다. 본문 PartialProduct는 id도 선택적이며 저장 정책상 금지 필드가 자동 배제되지는 않습니다.
RELATIONSHIP
같은 T를 입력과 callback에
GenericList의 items: T[]와 renderItem(item: T)를 연결합니다. T extends Item 제약의 id와 실제 User/Product 속성은 함께 추론되며 any 인덱스 signature가 모든 필드를 검증해 주지는 않습니다.
EXTERNAL INPUT
unknown은 값 구조를 검사
isImageContent는 이미 Content인 값의 type tag만 좁힙니다. 외부 unknown에는 객체 여부, src·alt 등 필드 타입을 확인하는 guard나 schema가 별도로 필요합니다.
RENDER BRANCH
판별 union과 never
각 status에 맞는 payload와 switch 분기를 함께 정의합니다. assertNever로 새 variant의 누락을 드러내되 실제 state 전이와 runtime 입력 검사는 따로 구현합니다.
CONFIGURATION
as const와 satisfies
routes의 as const는 리터럴/readonly 타입을 보존하고 satisfies는 RouteConfig 계약을 검사합니다. 런타임 동결이나 외부 설정 검증이 아니며 satisfies만으로 모든 값이 좁은 리터럴로 유지된다고 가정하지 않습니다.
작은 계약으로 구체적인 실패를 막습니다. 정적 타입 검사, 사용자 동작 테스트, 외부 입력 검증은 각각의 역할을 가집니다.