TypeScript 독학 가이드 8 - 제네릭
“이 함수는 number도 받고 string도 받고 싶은데, 타입은 안전하게 유지하고 싶어요.” 이럴 때 제네릭이 답이에요. any 쓰면 타입 안전성이 사라지잖아요? 제네릭은 타입을 변수처럼 다룰 수 있어서 완전 강력해요.
제네릭이 왜 필요할까?
먼저 문제 상황을 봅시다:
function getFirstElement(arr: number[]): number {
return arr[0];
}
const numbers = [1, 2, 3];
console.log(getFirstElement(numbers)); // OK
const strings = ["a", "b", "c"];
// console.log(getFirstElement(strings)); // 에러! string[] 안 받음
실행 결과:
number 배열만 받을 수 있어서 string 배열은 사용할 수 없습니다
any 쓰면?
function getFirstElement(arr: any[]): any {
return arr[0];
}
const result = getFirstElement([1, 2, 3]);
// result의 타입이 any... 타입 안전성 사라짐
제네릭 쓰면 완벽하게 해결!
function getFirstElement<T>(arr: T[]): T {
return arr[0];
}
const num = getFirstElement([1, 2, 3]); // number
const str = getFirstElement(["a", "b", "c"]); // string
const bool = getFirstElement([true, false]); // boolean
console.log(num);
console.log(str);
console.log(bool);
실행 결과:
제네릭으로 타입 안전성을 유지하면서 재사용 가능한 함수를 만들었습니다
<T>는 타입 변수예요. 함수를 호출할 때 타입이 결정됩니다.
제네릭 함수
기본 사용법
function identity<T>(value: T): T {
return value;
}
console.log(identity<number>(123));
console.log(identity<string>("안녕"));
console.log(identity(true)); // 타입 추론으로 boolean
실행 결과:
제네릭 타입을 명시하거나 추론에 맡길 수 있습니다
여러 타입 변수
function pair<T, U>(first: T, second: U): [T, U] {
return [first, second];
}
const result1 = pair<number, string>(1, "one");
const result2 = pair(true, 123); // 타입 추론
console.log(result1);
console.log(result2);
실행 결과:
두 개의 다른 타입을 동시에 다룰 수 있습니다
화살표 함수
const reverse = <T>(arr: T[]): T[] => {
return arr.reverse();
};
console.log(reverse([1, 2, 3, 4, 5]));
console.log(reverse(["a", "b", "c"]));
실행 결과:
화살표 함수에서도 제네릭을 사용할 수 있습니다
제네릭 인터페이스
인터페이스에도 제네릭을 사용할 수 있어요:
interface Box<T> {
value: T;
}
const numberBox: Box<number> = { value: 123 };
const stringBox: Box<string> = { value: "안녕" };
const boolBox: Box<boolean> = { value: true };
console.log(numberBox);
console.log(stringBox);
console.log(boolBox);
실행 결과:
제네릭 인터페이스로 다양한 타입의 Box를 만들 수 있습니다
API 응답 타입
실무에서 자주 쓰는 패턴:
interface ApiResponse<T> {
success: boolean;
data: T;
timestamp: number;
}
interface User {
id: number;
name: string;
}
interface Product {
id: number;
title: string;
price: number;
}
const userResponse: ApiResponse<User> = {
success: true,
data: { id: 1, name: "김철수" },
timestamp: Date.now()
};
const productResponse: ApiResponse<Product> = {
success: true,
data: { id: 101, title: "노트북", price: 1500000 },
timestamp: Date.now()
};
console.log(userResponse.data.name);
console.log(productResponse.data.title);
실행 결과:
같은 응답 구조로 다양한 데이터 타입을 처리합니다
제네릭 클래스
클래스에도 제네릭 사용 가능:
class Stack<T> {
private items: T[] = [];
push(item: T) {
this.items.push(item);
}
pop(): T | undefined {
return this.items.pop();
}
peek(): T | undefined {
return this.items[this.items.length - 1];
}
size(): number {
return this.items.length;
}
}
const numberStack = new Stack<number>();
numberStack.push(1);
numberStack.push(2);
numberStack.push(3);
console.log(`마지막 요소: ${numberStack.peek()}`);
console.log(`꺼낸 요소: ${numberStack.pop()}`);
console.log(`스택 크기: ${numberStack.size()}`);
const stringStack = new Stack<string>();
stringStack.push("a");
stringStack.push("b");
console.log(`꺼낸 요소: ${stringStack.pop()}`);
실행 결과:
제네릭 클래스로 타입 안전한 자료구조를 만들었습니다
제네릭 제약조건 (Constraints)
“어떤 타입이든 받는데, 특정 속성은 있어야 해!” 이럴 때 제약조건 씁니다.
extends 키워드
interface Lengthwise {
length: number;
}
function logLength<T extends Lengthwise>(item: T): void {
console.log(`길이: ${item.length}`);
}
logLength("안녕하세요"); // string은 length 있음 - OK
logLength([1, 2, 3]); // array도 length 있음 - OK
logLength({ length: 10 }); // 객체에 length 있으면 OK
// logLength(123); // number는 length 없음 - 에러!
실행 결과:
length 속성이 있는 타입만 받을 수 있습니다
여러 제약조건
interface Named {
name: string;
}
interface Aged {
age: number;
}
function introduce<T extends Named & Aged>(person: T): string {
return `${person.name}님은 ${person.age}살입니다`;
}
const person = { name: "김철수", age: 25, job: "개발자" };
console.log(introduce(person));
실행 결과:
name과 age 속성을 모두 가진 객체만 받습니다
keyof 연산자
객체의 키를 타입으로 쓸 수 있어요:
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const person = {
name: "김철수",
age: 25,
job: "개발자"
};
console.log(getProperty(person, "name"));
console.log(getProperty(person, "age"));
// console.log(getProperty(person, "salary")); // 에러! salary 없음
실행 결과:
객체에 존재하는 키만 사용할 수 있습니다
keyof T는 T의 모든 키를 유니온 타입으로 만들어요. 엄청 유용합니다!
기본 타입 매개변수
제네릭에 기본값을 줄 수 있어요:
interface Response<T = string> {
data: T;
status: number;
}
const response1: Response = {
data: "성공",
status: 200
};
const response2: Response<number> = {
data: 123,
status: 200
};
console.log(response1);
console.log(response2);
실행 결과:
타입을 지정하지 않으면 기본값인 string이 사용됩니다
실전 예제: 배열 유틸리티
제네릭으로 배열 유틸리티 함수 만들어볼게요:
function filterArray<T>(arr: T[], predicate: (item: T) => boolean): T[] {
return arr.filter(predicate);
}
function mapArray<T, U>(arr: T[], mapper: (item: T) => U): U[] {
return arr.map(mapper);
}
const numbers = [1, 2, 3, 4, 5];
const evenNumbers = filterArray(numbers, n => n % 2 === 0);
const doubled = mapArray(numbers, n => n * 2);
console.log(`짝수: ${evenNumbers}`);
console.log(`2배: ${doubled}`);
const words = ["hello", "world", "typescript"];
const longWords = filterArray(words, w => w.length > 5);
const upperWords = mapArray(words, w => w.toUpperCase());
console.log(`긴 단어: ${longWords}`);
console.log(`대문자: ${upperWords}`);
실행 결과:
제네릭으로 타입 안전한 유틸리티 함수를 만들었습니다
정리하면
- 제네릭: 타입을 변수처럼 다루는 기능
<T>: 타입 변수, 관례상 T, U, V 등 사용- 제네릭 함수: 함수 이름 뒤에
<T> - 제네릭 인터페이스: 인터페이스 이름 뒤에
<T> - 제네릭 클래스: 클래스 이름 뒤에
<T> - 제약조건:
extends로 타입 제한 - keyof: 객체 키를 타입으로 사용
다음 글에서는 유틸리티 타입에 대해 배워볼게요. Partial, Required, Pick, Omit 같은 TypeScript 내장 타입들입니다!