프로그래밍에서는 데이터를 저장하고 활용하기 위해 변수를 사용하며, 이는 값이 변할 수 있음을 의미합니다. 반대로, `number[]`, `string` 같은 타입은 한 번 정해지면 변하지 않는 고정된 타입입니다. 하지만 개발 환경에서는 상황이 항상 일정하지 않으며, 보다 유연한 타입 관리가 필요합니다. 이를 가능하게 하는 것이 바로 제너릭(Generic) 으로, "타입을 변수처럼 활용하는 기법" 입니다.
2. 제네릭 기본 문법
제네릭은 타입을 함수의 인자처럼 사용합니다. 선언할 때는 타입 파라미터 `<T>` 를 적어주고, 생성하는 시점에 사용하는 타입을 결정해줍니다.
`<T>`: 타입 파라미터, 어떤 타입을 전달 받아 해당 함수에서 사용
function add<T>(x: T, y: T): T {
return x + y;
}
// 호출시 타입을 정해주면, 함수의 T 부분이 해당타입으로 바뀌어 실행
add<number>(1, 2); // 3
add<string>('Hi', ' Beautiful-Code'); // 'Hi Beautiful-Code'
💡 꺽쇠 (<>) 기호는 변수명, 함수명 앞에 사용됩니다. 그리고 제너릭 이름은 반드시 T 일 필요는 없습니다.
3. 제네릭 제약 조건
제네릭은 사용하는 시점에 타입을 결정하여 사실상 어떤 타입이든 넣을 수 있습니다. 이렇게 입력값에 대한 유연성을 확보할 수 있지만, 특정 함수에서는 사용 목적에 따라 입력값을 제한해야 할 때가 있습니다. 이를 해결하기 위해 타입스크립트의 제네릭은 적용할 수 있는 타입의 범위를 제한하는 기능을 제공합니다.
함수에서 제네릭을 사용하면 다양한 타입의 매개변수를 받을 수 있습니다. 하지만 무분별하게 모든 타입을 허용하면 예상치 못한 오류가 발생할 수 있습니다. 따라서 특정 타입만 허용하도록 제약 조건을 설정할 수 있습니다. 아래 예제에서는 `T`가 `number | string` 중 하나여야 한다는 제약을 설정했습니다. 따라서 `boolean` 타입의 값은 허용되지 않습니다.
function printValue<T extends number | string>(value: T): void {
console.log(value);
}
printValue(123); // 정상
printValue("Hello"); // 정상
printValue(true); // 오류: T는 number 또는 string 타입이어야 함
3) 함수 제약조건
제네릭을 사용하여 함수의 입력과 출력을 특정 타입으로 제한할 수 있습니다. 예를 들어, 아래 함수는 매개변수로 받은 값이 반드시 `length` 속성을 포함해야 합니다. 즉, 문자열, 배열, 객체 등 `length` 속성이 있는 타입만 전달할 수 있습니다.
function getLength<T extends { length: number }>(arg: T): number {
return arg.length;
}
console.log(getLength("Hello")); // 정상: 문자열은 length 속성을 가짐
console.log(getLength([1, 2, 3])); // 정상: 배열도 length 속성을 가짐
console.log(getLength({ length: 10 })); // 정상
console.log(getLength(100)); // 오류: 숫자는 length 속성이 없음
4. 제네릭 함수 타입
우리는 타입스크립트에서 함수 자체도 하나의 타입으로 지정할 수 있다고 배웠습니다. 예를 들어, 다음과 같이 함수의 타입 구조를 미리 정해놓으면, 이 구조에 맞는 함수만 할당할 수 있도록 제한할 수 있습니다. 이렇게 하면 타입 안정성이 강화되고, 코드의 일관성을 유지할 수 있습니다.
function identity<T>(arg: T): T {
return arg;
}
const stringIdentity: <T>(arg: T) => T = identity;
console.log(stringIdentity<string>("Generic Function")); // "Generic Function"
const numberIdentity: <T>(arg: T) => T = identity;
console.log(numberIdentity<number>(42)); // 42
💡 함수 타입을 정해주는 이유?
- 여러 사람이 함께 개발할 때, 함수의 사용 방식을 통일할 수 있습니다. - 의도와 다른 타입이 들어오는 것을 방지하여 버그를 줄일 수 있습니다. - 코드의 가독성이 좋아지고 유지보수가 쉬워집니다.
5. 제네릭 클래스 타입
제네릭 클래스에서도 제네릭을 사용하면 클래스에서 다룰 타입을 유연하게 지정할 수 있습니다. 앞서 살펴본 제네릭 인터페이스와 비슷한 개념으로, 클래스 내부의 속성과 메서드가 특정 타입을 유지하면서도 다양한 타입을 지원하도록 설계할 수 있습니다. 즉, 제네릭 클래스를 사용하면 클래스의 타입 안정성을 유지하면서도 재사용성을 높일 수 있습니다.
class Storage<T> {
private items: T[] = []; // 제네릭을 사용하여 배열 타입을 지정
addItem(item: T): void {
this.items.push(item);
}
getItems(): T[] {
return this.items;
}
}
// 문자열을 저장하는 Storage 인스턴스
const stringStorage = new Storage<string>();
stringStorage.addItem("Apple");
stringStorage.addItem("Banana");
console.log(stringStorage.getItems()); // ["Apple", "Banana"]
// 숫자를 저장하는 Storage 인스턴스
const numberStorage = new Storage<number>();
numberStorage.addItem(10);
numberStorage.addItem(20);
console.log(numberStorage.getItems()); // [10, 20]