일상적인 코드를 위한 TypeScript 제네릭
배열의 첫 번째 요소를 반환하는 함수를 작성하고 있습니다. 숫자, 문자열, 객체 모두에서 작동하길 원하지만 반환 타입이 입력 타입과 일치하기를 원합니다. 제네릭 없이는 any를 사용해야 하고 모든 타입 안정성을 잃게 됩니다. 여기서 TypeScript 제네릭이 필요합니다.
제네릭을 사용하면 특정 타입 정보를 유지하면서 여러 타입과 작동하는 코드를 작성할 수 있습니다. 이 가이드에서는 일상적인 TypeScript 코드에서 제네릭을 사용하는 실용적인 패턴을 살펴보겠습니다.
제네릭이란 무엇인가?
제네릭은 타입을 매개변수화하는 방법입니다. 함수가 매개변수를 받는 것처럼 제네릭은 타입 매개변수를 받습니다. 이를 통해 단일 타입이 아닌 다양한 타입에서 작동하는 재사용 가능한 컴포넌트를 만들 수 있습니다.
다음 항등 함수를 살펴보세요:
function identity<T>(arg: T): T {
return arg;
}
여기서 T는 타입 변수입니다. identity(42)를 호출하면 TypeScript는 T를 number로 추론합니다. identity("hello")를 호출하면 T는 string이 됩니다. 반환 타입은 항상 입력 타입과 일치합니다.
실제 제네릭 함수
값을 객체로 감싸는 함수라는 일반적인 시나리오를 살펴보겠습니다.
function wrapInObject<T>(value: T): { value: T } {
return { value };
}
제네릭 없이는 any나 unknown을 사용해야 하고 호출자는 타입 정보를 잃게 됩니다. 제네릭을 사용하면 반환 타입이 정확히 { value: T }입니다.
또 다른 일상적인 예는 배열의 마지막 요소를 반환하는 함수입니다:
function last<T>(arr: T[]): T | undefined {
return arr[arr.length - 1];
}
TypeScript는 배열 인수에서 T를 추론하므로 last([1, 2, 3])은 number | undefined를 반환합니다.
제네릭 제약
때로는 제네릭과 함께 사용할 수 있는 타입을 제한하고 싶을 수 있습니다. 예를 들어 특정 타입에만 존재하는 속성에 접근해야 할 수 있습니다. extends 키워드를 사용하여 제약을 추가할 수 있습니다.
function getLength<T extends { length: number }>(item: T): number {
return item.length;
}
이제 T는 number 타입의 length 속성을 가져야 합니다. 이는 문자열, 배열 및 length 속성이 있는 모든 객체에 적용됩니다.
인터페이스와 함께 제약을 사용할 수도 있습니다:
interface HasId {
id: string;
}
function findById<T extends HasId>(items: T[], id: string): T | undefined {
return items.find(item => item.id === id);
}
이렇게 하면 findById에 전달된 항목에 id 속성이 있는지 확인합니다.
제네릭 인터페이스와 타입 별칭
제네릭은 함수에만 국한되지 않습니다. 제네릭 인터페이스와 타입 별칭을 정의하여 유연한 데이터 구조를 만들 수 있습니다.
interface ApiResponse<T> {
data: T;
status: number;
error?: string;
}
async function fetchData<T>(url: string): Promise<ApiResponse<T>> {
const response = await fetch(url);
const data = await response.json();
return { data, status: response.status };
}
이제 API 응답을 정확하게 입력할 수 있습니다:
interface User {
id: number;
name: string;
}
const userResponse = await fetchData<User>('/api/user');
// userResponse.data is User
제네릭 클래스
클래스도 제네릭이 될 수 있습니다. 일반적인 예는 간단한 스택입니다:
class Stack<T> {
private items: T[] = [];
push(item: T): void {
this.items.push(item);
}
pop(): T | undefined {
return this.items.pop();
}
peek(): T | undefined {
return this.items[this.items.length - 1];
}
}
const numberStack = new Stack<number>();
numberStack.push(1);
const num = numberStack.pop(); // number | undefined
React 컴포넌트에서의 제네릭
React 컴포넌트를 구축하는 경우 제네릭을 사용하면 컴포넌트를 더 재사용 가능하게 만들 수 있습니다. 예를 들어 모든 타입의 항목을 렌더링하는 목록 컴포넌트:
interface ListProps<T> {
items: T[];
renderItem: (item: T) => React.ReactNode;
}
function List<T>({ items, renderItem }: ListProps<T>) {
return <ul>{items.map((item, index) => <li key={index}>{renderItem(item)}</li>)}</ul>;
}
이제 List를 모든 타입과 함께 사용할 수 있고 renderItem 콜백에 대한 전체 타입 검사를 받을 수 있습니다.
일반적인 함정과 모범 사례
- 제네릭을 남용하지 마세요. 함수가 특정 타입에서만 작동한다면 해당 타입을 사용하세요. 제네릭은 복잡성을 추가합니다.
- 설명적인 타입 매개변수 이름을 사용하세요.
T가 일반적이지만 여러 매개변수의 경우TInput,TOutput과 같은 이름을 사용하세요. any보다 제약을 선호하세요. 제약은 유연하면서도 타입 안정성을 제공합니다.- 제네릭은 런타임에 지워진다는 것을 기억하세요. 컴파일 시간에만 존재합니다.
비교: 제네릭 vs. any vs. 유니온 타입
| 접근 방식 | 타입 안정성 | 유연성 | 사용 사례 |
|---|---|---|---|
any |
없음 | 높음 | JS 마이그레이션, 빠른 프로토타입 |
| 유니온 타입 | 좋음 | 알려진 타입으로 제한됨 | 가능한 모든 타입을 알 때 |
| 제네릭 | 강력함 | 높음 | 타입이 보존된 재사용 가능한 컴포넌트 |
FAQ
any 대신 제네릭을 언제 사용해야 하나요?
함수나 컴포넌트 전체에서 타입 정보를 보존하려면 제네릭을 사용하세요. any는 타입 검사를 비활성화하지만 제네릭은 유지합니다. 무언가를 작동시키기 위해 any를 사용하게 된다면 제네릭이 더 안전하게 해결할 수 있는지 고려하세요.
여러 타입 매개변수를 사용할 수 있나요?
예. function pair<K, V>(key: K, value: V): [K, V]와 같이 여러 타입 매개변수를 가진 함수를 정의할 수 있습니다. 두 개 이상일 때는 설명적인 이름을 사용하세요.
.tsx 파일에서 화살표 함수와 함께 제네릭은 어떻게 작동하나요?
.tsx 파일에서 <T>는 JSX와 모호할 수 있습니다. 후행 쉼표를 사용하세요: const identity = <T,>(arg: T): T => arg; 또는 extends unknown을 사용하세요: <T extends unknown>.
제네릭은 TypeScript 코드를 더 재사용 가능하고 타입 안전하게 만들 수 있는 강력한 기능입니다. 간단한 제네릭 함수로 시작한 다음 점차 인터페이스와 클래스에 적용하세요. 타입 안정성을 유지하면서 JSON 데이터를 포맷하거나 검증해야 한다면 JSON Formatter 도구를 확인하여 JSON을 빠르게 검사하고 예쁘게 출력하세요.