
제네릭(Generic)을 직역하면 '일반적인', '포괄적인' 이라는 뜻이다. 그렇다면 제네릭 함수는 '일반적인 함수' 라는 건데, 의미가 크게 와닿지는 않는다.
그럼 제네릭의 동의어인 제너럴(General)로 생각해보자. 제너럴은 어디에 많이 쓰일까? 바로 General Hospital, 종합 병원이라는 단어다.
종합병원은 모든 종류의 질병을 두루두루 치료할 수 있는 병원을 의미한다. 특정 질병에 특화된 것이 아니라 여러 가지 병을 다룰 수 있는 범용적인 병원이다. 이와 마찬가지로 제네릭(Generic)이란 특정한 타입에 종속되지 않고, 여러 타입을 두루두루 아우를 수 있는 기능을 의미한다.
TypeScript에서는 변수를 선언할 때 타입을 지정할 수 있다. 하지만 하나의 타입만을 고정적으로 사용하면 확장성이 떨어지게 된다.
예를 들어 다음과 같은 함수가 있다고 해보자
function func(value: any) {
return value;
}
이 함수는 매개변수로 숫자를 받을 수도, 문자열을 받을 수도, 불리언 타입을 받을 수도 있는 아주 범용적인 함수의 역할을 해야 한다.
그러면 매개변수의 타입은 any로 지정해야겠다는 생각이 든다.
let num = func(10);
let bool = func(true);
let str = func('string');
그러면 이때 이 변수 num, bool, str의 타입은 뭐가 될까?
사실 우리가 기대하는 바는 이 함수가 매개변수를 그대로 반환하는 함수이니
num이라는 변수의 타입은 우리가 전달하는 인수의 타입인 number 타입이 됐으면 좋겠고
bool 변수의 타입은 또 boolean 타입이었으면 좋겠고
str 변수의 타입은 string 이었으면 좋겠다고 생각한다.
하지만 그렇게 되지 않는다. 이 세 개의 변수는 모두 any 타입으로 추론된다.
함수의 반환 값 타입은 해당 함수의 리턴 값을 기준으로 추론된다.
따라서 이 func 함수는 매개변수인 value를 그대로 리턴하는 함수고, value의 타입이 any 이므로 단순히 이 함수도 반환 값이 any 타입이 되는 것이다.
그러면 이런 문제가 있으니 value의 타입을 좀 더 안정성있게 unknown 타입으로 바꿔보자.
function func(value: unknown) {
return value;
}
let num = func(10);
num.toFixed(); // 💥 'num'은(는) 'unknown' 형식입니다.
그렇게 되면 아까 any 타입으로 했을 때와는 다르게 빨간 줄로 오류가 발생한다.
아까와 마찬가지로 매개변수가 unknown 타입이기 때문에 반환 값도 마찬가지로 unknown 타입으로 잡혀서 오류가 발생하는 것이다.
오류를 알려주는 건 좋다. 그치만 진짜 문제는 이 변수 num에 숫자 값이 들어가는 건 너무나 자명한 상황인데도 toFixed같은 메서드를 쓸 수 없다는 것이다.
이 상황에서 실제 num 변수를 숫자처럼 쓰려면
let num = func(10);
if(typeof num === "number") {
num.toFixed();
}
이렇게 typeof num === "number" 와 같이 조건문을 이용해 type을 좁혀서 사용해야 한다.
이런 과정때문에 매개변수를 unknown 타입으로 정의하는 것도 참 불편하다.
우리가 원하는 건 참 간단한데, 인수로 number 타입을 넣으면 반환 값도 number, boolean 타입을 넣으면 반환 값도 boolean, string 타입을 넣으면 반환 값도 string 이렇게 융통성있는 함수를 만들고 싶은 거다. 하지만 지금까지의 TypeScript 지식으로는 구현하기가 어렵다.
이럴 때 사용하면 좋은 기능이 바로 제네릭(Generics)다.
제네릭을 사용하면 타입을 매개변수처럼 설정해 유연하게 활용할 수 있다. 사용법은 아래와 같다.
function func<T>(value: T): T {
return value;
}
<T> : 꺽쇄안에 들어간 T는 타입을 저장하는 변수, 즉 '타입 변수'가 된다. 타입 변수는 함수를 호출할 때 인수의 타입이 어떤 타입이냐에 따라 저장되는 타입이 달라진다.value도 T, 반환값도 T 타입이 되고, 이는 value에 어떤 타입이 들어오냐에 따라 대응을 하는 융통성을 갖춰주는 역할을 한다.따라서 다음과 같이 제네릭 함수를 정의하게 되면
let num = func(10);
let bool = func(true);
let str = func('string');
아까 선언했던 num과 bool, str이 각각 number, boolean, string 타입으로 처리된다! 😃
제네릭은 인터페이스에서도 사용할 수 있다.
interface Box<T> {
value: T;
}
const numberBox: Box<number> = { value: 100 };
const stringBox: Box<string> = { value: "Hello" };
Box<T>는 타입을 자유롭게 설정할 수 있는 확장성이 뛰어난 인터페이스가 된다.제네릭은 클래스에서도 사용할 수 있는데,
class DataStore<T> {
private data: T;
constructor(value: T) {
this.data = value;
}
getData(): T {
return this.data;
}
}
const numberStore = new DataStore<number>(10);
console.log(numberStore.getData()); // 10
const stringStore = new DataStore<string>("Hello");
console.log(stringStore.getData()); // "Hello"
DataStore<T>를 사용하면 다양한 타입을 저장, 반환할 수 있게 된다.extends)하지만 또 모든 타입을 유도리있게 허용하다 보면 예기치 못한 오류를 발생할 수 있으니 어느 정도의 조건에 따른 타입만 허용하는 기능도 필요할 것 같다. 그런 경우에는 extends를 활용할 수 있다.
function printLength<T extends { length: number }>(item: T) {
console.log(item.length);
}
printLength("Hello"); // 5 (문자열)
printLength([1, 2, 3]); // 3 (배열)
printLength({ length: 10 }); // 10 (객체)
printLength(42); // ❌ 오류 발생! (number 타입에는 length 속성이 없음)
T extends {length: number} : T는 반드시 length 속성을 가져야만 한다는 조건이 추가되었다. 따라서 printLength(42)는 length 속성이 없기 때문에 오류가 발생한다.extends를 사용할 수 있다.제네릭은 여러 개 사용할 수도 있는데,
function pair<K, V>(key: K, value: V): [K, V] {
return [key, value];
}
const result = pair("id", 123);
console.log(result); // ["id", 123] (string, number)
pair<K, V> : 두 개의 타입을 동적으로 설정해서 서로 다른 타입의 값을 하나의 함수에서 처리할 수 있도록 해준다. 예를 들어 key는 문자열 타입이고, value는 숫자 타입일 수도 있으며 그 반대일 수도 있다. 이를 통해 보다 유연한 데이터 구조를 정의할 수 있게 된다.extends를 사용해 특정 조건을 만족하는 타입만 허용 가능제네릭은 TypeScript가 제공하는 아주 강력한 기능 중 하나이니 꼭꼭 씹어서 소화하고 넘어가자!