# TypeScript 제네릭(Generics) 완벽 가이드
Table of Contents
제네릭(Generics)이란?
제네릭은 C#이나 Java 같은 언어에서 강력한 도구로 사용되던 기능으로, TypeScript에서도 타입을 파라미터화하여 코드의 재사용성을 극대화하는 핵심 기능입니다. 단일 타입이 아닌 다양한 타입에서 작동하는 컴포넌트를 만들면서도, any 타입을 쓸 때와 달리 **타입 안전성(Type Safety)**을 잃지 않는다는 점이 가장 큰 장점입니다.
기본 제네릭 함수
먼저 가장 기본적인 형태의 제네릭 함수를 살펴보겠습니다.
function identity<T>(arg: T): T {
return arg;
}
// 1. 명시적 타입 지정
const output1 = identity<string>('Hello'); // 타입이 string으로 고정됨
const output2 = identity<number>(42); // 타입이 number로 고정됨
// 2. 타입 추론 (Type Inference) - 권장
const output3 = identity('TypeScript'); // TypeScript가 자동으로 string으로 추론
const output4 = identity(100); // TypeScript가 자동으로 number로 추론
:::tip
대부분의 경우 TypeScript가 문맥을 통해 제네릭 타입을 자동으로 추론하므로, 굳이 <string>처럼 명시적으로 타입을 적어줄 필요는 없습니다. 추론에 맡기면 코드가 훨씬 간결해집니다.
:::
제네릭으로 배열 다루기
제네릭은 배열이나 리스트 형태의 데이터를 다룰 때 그 진가를 발휘합니다.
function getFirst<T>(arr: T[]): T | undefined {
return arr[0];
}
// Array<T> 문법도 동일하게 작동합니다
function getLast<T>(arr: Array<T>): T | undefined {
return arr[arr.length - 1];
}
const numbers = [1, 2, 3, 4, 5];
const firstNum = getFirst(numbers); // number | undefined
const names = ['Alice', 'Bob', 'Charlie'];
const firstName = getFirst(names); // string | undefined
제네릭 제약 조건 (Generic Constraints)
“모든 타입”을 허용하는 것이 항상 좋은 것은 아닙니다. 때로는 특정 속성이나 메서드를 가진 타입만 받도록 제한해야 할 때가 있습니다. 이때 extends 키워드를 사용합니다.
interface Lengthwise {
length: number;
}
// T는 반드시 length 속성을 가진 타입이어야 함
function logLength<T extends Lengthwise>(arg: T): T {
console.log(arg.length); // 이제 length 속성에 안전하게 접근 가능
return arg;
}
logLength('Hello'); // 성공: string은 length가 있음
logLength([1, 2, 3]); // 성공: array는 length가 있음
logLength({ length: 10 }); // 성공: 객체에 length가 있음
// logLength(100); // 오류: number에는 length가 없음
keyof를 활용한 객체 속성 제약
객체의 속성을 안전하게 가져오기 위해 keyof 연산자와 함께 사용할 수 있습니다.
// K는 T의 키(key) 중 하나여야 함
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const person = {
name: 'Alice',
age: 30,
email: 'alice@example.com'
};
const name = getProperty(person, 'name'); // string 반환
const age = getProperty(person, 'age'); // number 반환
// getProperty(person, 'address'); // 오류: 'address'는 person의 키가 아님
제네릭 클래스
데이터를 저장하거나 관리하는 클래스를 만들 때 제네릭은 필수적입니다.
class DataStore<T> {
private items: T[] = [];
add(item: T): void {
this.items.push(item);
}
remove(item: T): void {
const index = this.items.indexOf(item);
if (index > -1) {
this.items.splice(index, 1);
}
}
getAll(): T[] {
return [...this.items]; // 안전하게 복사본 반환
}
}
// 숫자 저장소
const numberStore = new DataStore<number>();
numberStore.add(1);
numberStore.add(5);
// 문자열 저장소
const stringStore = new DataStore<string>();
stringStore.add('TypeScript');
다중 제네릭 타입
여러 개의 독립적인 타입 매개변수가 필요할 때는 쉼표로 구분하여 정의합니다.
// 두 객체를 병합하는 함수
function merge<T, U>(obj1: T, obj2: U): T & U {
return { ...obj1, ...obj2 };
}
const merged = merge(
{ name: 'Alice' },
{ age: 30 }
);
// 반환 타입: { name: string } & { age: number }
실전 활용 패턴
1. API 응답 래퍼 (Response Wrapper)
백엔드 API의 응답 구조를 정의할 때 가장 많이 사용되는 패턴입니다.
// 기본값이 있는 제네릭 인터페이스
interface ApiResponse<Data = unknown> {
statusCode: number;
message: string;
data: Data;
}
interface UserProfile {
id: number;
username: string;
}
// 구체적인 타입 적용
const response: ApiResponse<UserProfile> = {
statusCode: 200,
message: 'Success',
data: {
id: 1,
username: 'dev_kim'
}
};
2. 유틸리티 타입 구현 (Result Pattern)
에러 처리를 우아하게 하기 위한 Result 타입을 구현할 수 있습니다.
type Result<T, E = Error> =
| { success: true; value: T }
| { success: false; error: E };
function divide(a: number, b: number): Result<number, string> {
if (b === 0) {
return { success: false, error: '0으로 나눌 수 없습니다.' };
}
return { success: true, value: a / b };
}
const result = divide(10, 0);
if (result.success) {
console.log(result.value);
} else {
console.error(result.error); // '0으로 나눌 수 없습니다.'
}
모범 사례 (Best Practices)
- 의미 있는 이름 사용: 단순한 경우에는
T,U도 좋지만, 복잡해지면TData,TResponse,TItem처럼 의미를 명확히 드러내는 이름을 사용하세요. - 가능한 한 제약 걸기:
any처럼 동작하지 않도록,extends를 사용해 가능한 한 타입을 좁혀주는 것이 좋습니다. - 기본 타입(Default Type) 제공:
interface Wrapper<T = string>처럼 자주 사용되는 타입을 기본값으로 제공하면 사용하기 편리해집니다.
마치며
제네릭은 TypeScript를 단순한 “타입 있는 자바스크립트”에서 “견고한 아키텍처를 위한 언어”로 격상시키는 핵심 기능입니다. 처음에는 문법이 낯설 수 있지만, 익숙해지면 라이브러리 코드를 읽거나 재사용 가능한 유틸리티를 만들 때 없어서는 안 될 도구가 될 것입니다.