
요새 일이 너무 바빠서 블로그를 쓸 힘이 없었다.. 거의 3주동안 못썼는데, 다시 마음을 다잡고 일주일에 하나씩은 써보도록 해야겠다 ㅠ_ㅠ
오늘은 타입스크립트의 제네릭에 대해서 알아보도록 할거다.
개발을 하다 보면 어떤 타입에 종속되지 않는 로직을 만들고 싶을 때가 많다. 예를 들어 배열의 첫 번째 값을 반환하는 함수 같은 경우가 대표적인데, 숫자 배열일 수도 있고, 문자열 배열일 수도 있고, 객체 배열일 수도 있다.
그렇다고 매번 any[]로 받을 수는 없다. 그렇게 하면 타입 추론이 사라지고 타입스크립트를 쓰는 의미가 없어진다. 그때 등장하는게 제네릭(Generic)이다.
가장 먼저 했던 고민은 이거였다.
"지금 이 함수는 number[]일 때도, string[]일 때도 돌아가는데... 그럼 타입을 뭘로 적어야 하지..?"
function getFirst(arr: any[]): any {
return arr[0];
}
이렇게 any를 쓰면 모든 타입을 받을 수 있는 것 같긴 한데, 그 순간부터 타입스크립트의 타입 보호를 전혀 못 받게 된다. 예를 들어 아래 코드는 에러가 안 나지만, 런타임에 터질 가능성이 있다.
const name = getFirst(['Lee', 'Kim']);
name.toUpperCase(); // 문제 없음. 하지만 number 배열을 넣었다면??
그래서 등장하는게 바로 제네릭 함수다.
function getFirst<T>(arr: T[]): T {
return arr[0];
}
여기서 <T>는 그냥 "아무 타입"이라는 의미가 아니라, 호출하는 시점에서 타입을 넘겨 줄 수 있도록 만드는 자리다. 함수 내부에서는 이 T라는 타입이 계속 이어지니까, 입력이 string[]이면 출력도 string이고, number[]이면 출력도 number가 된다.
const name = getFirst(['Lee', 'Kim']); // string
const age = getFirst([10,20,30]); // number
타입을 추론해주는 것도 좋지만, 필요하면 명시적으로도 적을 수 있다.
const result = getFirst<number>([1,2,3]);
단순히 "타입을 나중에 정한다"는 것보다 중요한 타입 간의 관계를 유지해준다는 거다. 이걸 깨닫고 나서 제네릭이 왜 그렇게 강력한지 체감됐다.
예를 들어, 다음과 같은 코드를 봐보자.
function wrap<T>(value: T): {value: T} {
return { value };
}
이 함수는 어떤 타입을 받든 간에, 그 타입을 value 라는 키에 넣어 감싼 객체를 반환한다. 중요한 건, 반환 타입이 단순한 {value:any}가 아니라, 전달받은 T 그대로라는 점이다.
const wrapped = wrap('hello'); // {value: string}
이처럼 제네릭을 쓰면, 입력과 출력이 하나의 타입으로 연결되기 때문에 타입 안정성이 높아진다. 객체의 일부를 복사하는 유틸 함수나, API 응답을 처리하는 함수에서도 이 패턴이 유용하게 쓰인다.
실제로 유틸 함수들을 만들다 보면 두 개 이상의 타입 관계를 설정하고 싶을 때도 있다.
function merge<A,B>(a: A, b: B): A & B {
return { ...a, ...b };
}
const merged = merge({ name: 'Lee' },{ age: 27});
// merged: { name: string; age: number }
여기서 A & B는 타입 병합을 사용한 건데, 두 객체를 합친 결과의 타입도 자동으로 보장된다. 이걸 직접 명시하면 상당히 귀찮고 헷갈리는데, 제네릭으로 자연스럽게 해결된다.
처음엔 제네릭을 쓰다 보면 너무 자유로워서 타입 에러가 잘 안나서 당황했다. 예를 들어
function printLength<T>(value: T) {
return value.length; // 에러!
}
왜냐하면 T는 어떤 타입일지 모르기 때문에 length 프로퍼티가 있다는 걸 보장할 수 없다는 거다. 그럴 땐 제네릭에 제약을 줄 수 있다.
function printLength<T extends { length: number }>(value: T) {
return vlaue.length; // 에러 X
}
이렇게 하면 length 가 있는 타입만 받을 수 있게 된다. 문자열, 배열, length가 있는 객체 등은 모두 통과가 된다.
결국 제네릭은 타입을 유연하게 받되, 그 사이의 관계를 안전하게 유지할 수 있도록 도와주는 도구다. 단순히 어떤 타입이든 받아서 쓰는 게 아니라, 호출 시점에 타입이 주입되고 그 타입이 함수 전체를 관통하면서 연결된다는 점이 제네릭의 진짜 핵심이다.
처음엔 괜히 복잡하고 과한 문법처럼 보였지만, 프로젝트 규모가 커지고 타입 복잡도가 올라가면 갈수록 제네릭 없이는 만들 수 없는 함수나 구조들이 생겨난다.
그래서 지금은 그냥 외워서 쓰기보다는, "이 함수의 타입이 고정돼 있지 않고 외부에서 정해졌으면 좋겠다" 싶은 순간마다 제네릭을 떠올려본다.
그게 아마 타입스크립트를 좀 더 잘 쓰는 방향 중 하나일 거라고 믿는다.