제네릭은 타입을 함수 인자처럼 전달받도록 추상화해, 실제 타입 결정을 사용 시점까지 미루는 장치다.
왜 필요한가
-
타입스크립트를 쓰는 목적은 결국 어떤 값이 어떤 타입인지를 컴파일 타임에 알기 위해서다.
-
그런데 실제 코드에서는 같은 로직이 여러 타입에 걸쳐 반복되는 상황이 자주 발생한다.
-
예를 들어 배열의 첫 요소를 꺼내는 함수는 숫자 배열, 문자열 배열, 객체 배열 모두에 동일하게 필요하다.
-
가장 쉬운 해결책은
any를 쓰는 것이지만,any를 반환하면 컴파일러는 그 반환값의 타입 검사를 포기하게 된다. -
그 결과 함수 내부의 타입 정보는 호출 지점까지 이어지지 못하고 끊겨버린다.
-
그렇다고
getFirstNumber,getFirstString처럼 타입마다 함수를 따로 만드는 것은 명백한 중복이다. -
제네릭은 이 딜레마를 정면으로 해결한다.
-
핵심 아이디어는 타입을 함수 인자처럼 받아서, 어떤 타입으로 호출되든 그 정보를 시그니처 전체에 일관되게 흘려보내자는 것이다.
-
실제 타입은 호출 시점에 결정되며, 이 결정은 모두 컴파일 타임에 이뤄진다.
기본 문법과 타입 추론
-
제네릭은 함수 이름 뒤에 꺾쇠
<T>를 붙여 타입 변수를 선언하는 것으로 시작한다. -
여기서
T는 실제 타입이 아니라 "아직 결정되지 않은 타입을 가리키는 이름표"다. -
이
T가 함수 시그니처 안에서 입력과 출력을 연결하는 역할을 한다.// T는 호출 시점에 결정되는 타입 변수다 // 입력 타입과 반환 타입이 모두 T로 묶여 있다 function identity<T>(value: T): T { return value; } -
위 함수는
T가 무엇이든 받은 값과 동일한 타입을 돌려준다는 계약을 명시한다. -
호출할 때 타입을 명시적으로 넘길 수도 있고, 그냥 생략하고 추론에 맡길 수도 있다.
// 1) 명시적으로 타입 전달 const a = identity<string>("hello"); // a: string // 2) 타입 추론: 인수 42를 보고 T = number로 자동 결정 const b = identity(42); // b: number -
후자의 경우 타입스크립트가 인수
42를 보고T = number임을 스스로 추론한다. -
이 타입 추론 덕분에 실제 코드에서는 꺾쇠를 직접 쓸 일이 생각보다 많지 않다.
-
중요한 점은
any와 달리 타입 정보가 함수를 통과한 뒤에도 그대로 살아있다는 것이다.
배열을 다루는 함수에서의 활용
-
제네릭의 효과가 가장 직관적으로 드러나는 곳이 배열 처리 함수다.
-
배열에서 첫 요소를 꺼내는 단순한 함수를
any와 제네릭으로 각각 비교해보자.// any 버전: 꺼낸 순간 타입 검사가 포기된다 function firstAny(arr: any[]): any { return arr[0]; } const val = firstAny([1, 2, 3]); // val: any val.toUpperCase(); // 컴파일러가 막아주지 못한다 → 런타임 에러 -
any버전에서는 꺼낸 값에.toFixed()를 써도.toUpperCase()를 써도 컴파일러가 아무 경고를 하지 않는다. -
잘못된 메서드 호출이 런타임에서야 터지는 것은 타입스크립트가 막아야 할 바로 그 상황이다.
// 제네릭 버전: 요소 타입 T가 반환 타입에 그대로 연결된다 function first<T>(arr: T[]): T { return arr[0]; } const num = first([1, 2, 3]); // num: number const str = first(["a", "b", "c"]); // str: string -
제네릭 버전은
first([1,2,3])의 결과가number로 추론되어, 이후 코드도 타입 안전하게 작성할 수 있다. -
arr: T[]와 반환 타입T라는 시그니처가 "입력 요소 타입과 반환 타입이 같다"는 관계를 컴파일러에게 알려주는 핵심이다.
제네릭과 유니온의 차이
-
제네릭을 처음 배울 때 가장 흔한 혼동은 유니온 타입과의 차이다.
-
둘 다 "여러 타입을 처리한다"는 점에서 비슷해 보이지만, 의미는 완전히 다르다.
-
같은 항등 함수를 유니온과 제네릭으로 각각 써보면 차이가 분명해진다.
// 유니온 버전: 인자 타입과 반환 타입은 별개로 선언된 정보다 function identityU(x: string | number): string | number { return x; } // 제네릭 버전: T를 통해 인자 타입과 반환 타입이 한 변수로 묶인다 function identityG<T>(x: T): T { return x; } -
유니온 버전에서
identityU("hi")의 반환 타입은 여전히string | number이며, 컴파일러는 그 값이 문자열이었다는 사실을 잊는다. -
반면 제네릭 버전에서
identityG("hi")는T가string으로 확정되어 반환 타입도string이 된다. -
즉 유니온은 "여러 타입 중 어느 하나가 들어올 수 있다"는 가능성의 집합을 의미한다.
-
제네릭은 "호출마다 하나의 구체 타입이 결정되고, 그 타입이 시그니처 전체에 일관되게 적용된다"는 관계를 의미한다.
-
이 차이 덕분에 제네릭은 타입 정보를 함수 호출 전후로 연결하는 다리 역할을 할 수 있다.
여러 타입 변수와 타입 간의 관계 표현
-
타입 변수는 하나로 제한되지 않으며, 여러 개를 동시에 선언해 타입 사이의 관계를 표현할 수 있다.
-
두 값을 받아 쌍으로 묶는 함수는 두 타입이 서로 독립적으로 결정되어야 한다.
// K와 V는 각각 독립적으로 추론된다 function zip<K, V>(key: K, value: V): [K, V] { return [key, value]; } const p1 = zip("age", 30); // [string, number] const p2 = zip(true, [1, 2]); // [boolean, number[]] -
K와V는 서로 영향을 주지 않고 각자 추론되므로 조합 가능한 타입의 범위가 매우 넓다. -
반대로 같은 타입 변수를 여러 자리에 쓰면, 그 자리들이 같은 타입을 공유한다는 의도를 시그니처에 새길 수 있다.
function pickOne<T>(a: T, b: T): T { return Math.random() > 0.5 ? a : b; } pickOne(1, 2); // T = number pickOne("a", "b"); // T = string // 주의: 타입을 명시하지 않으면 T가 string | number로 추론되어 통과된다 pickOne(1, "b"); // 같은 타입만 받도록 강제하려면 호출 시점에 T를 고정한다 pickOne<number>(1, "b"); // 오류: "b"는 number가 아니다 -
즉 같은 타입 변수를 반복하는 것은 강제라기보다 의도의 표현에 가깝다.
-
실제 강제는 호출 시 타입을 고정하거나 별도의 제약 조건을 거는 방식으로 달성한다.
-
여러 타입 변수든 반복되는 타입 변수든, 결국 핵심은 인자와 반환 사이의 관계를 시그니처에 새겨두는 것이다.
인터페이스에 제네릭 적용하기
-
제네릭은 함수뿐 아니라 타입의 구조를 정의하는 인터페이스에도 그대로 적용된다.
-
실무에서 가장 흔한 패턴은 API 응답 래퍼다.
-
응답의 뼈대인
status,message는 항상 같은데, 실제 데이터의 모양만 엔드포인트마다 달라지는 상황이 제네릭이 필요한 전형적인 시점이다.// 공통 구조는 고정, data의 타입만 T로 빼낸다 interface ApiResponse<T> { data: T; status: number; message: string; } // 각 엔드포인트에 맞게 T만 교체하면 된다 type UserResponse = ApiResponse<{ id: number; name: string }>; type PostListResponse = ApiResponse<{ title: string; body: string }[]>; -
UserResponse와PostListResponse는 외형을 공유하면서도data필드의 타입은 완전히 다르다. -
이 패턴은 타입 중복을 제거하는 동시에, 새 엔드포인트가 생겨도
T만 바꿔 끼우면 된다는 확장성을 제공한다.
제약 조건 extends
-
제네릭의 타입 변수는 호출 시점에 무엇이든 들어올 수 있으므로, 함수 내부에서 그 값의 구조를 가정할 수 없다.
-
그래서 프로퍼티 접근이나 메서드 호출처럼 특정 구조를 가정하는 동작은 컴파일러가 막는다.
// T가 어떤 타입인지 모르므로 length의 존재를 보장할 수 없다 function getLength<T>(value: T): number { return value.length; // 오류: 'length' 속성이 T에 존재한다고 볼 수 없음 } -
단 값을 그대로 돌려주거나, 다른 함수에 넘기거나, 동등 비교하는 등 구조에 의존하지 않는 동작은 여전히 가능하다.
-
이때
extends로 타입 변수에 제약 조건을 걸면, "이 조건을 만족하는 타입만 받겠다"고 선언할 수 있다.// T는 length: number를 가진 타입이어야 한다 function getLength<T extends { length: number }>(value: T): number { return value.length; // 정상: length 존재가 보장됨 } getLength("hello"); // 5 — string에는 length가 있다 getLength([1, 2, 3]); // 3 — 배열에도 length가 있다 getLength(42); // 오류 — number에는 length가 없다 -
이 함수를 말로 풀면,
length속성이number인 타입만T로 받고, 그T타입 값을 인자로 받아.length를number로 반환한다는 의미가 된다. -
즉
extends { length: number }는 "내부에서.length를 쓸 테니, 없는 타입은 들어오지 마라"고 컴파일러에게 미리 알리는 장치다. -
제약 조건은 제네릭의 자유도를 줄이는 것처럼 보이지만, 실제로는 함수 내부에서 구조에 기반한 동작을 가능하게 만드는 통로다.
keyof와 함께, 객체 접근을 타입 안전하게
-
extends는 다양한 곳에서 쓰이는데, 그중keyof와 결합되는 패턴은 특히 자주 등장한다. -
keyof T는 타입T의 모든 키를 유니온 타입으로 꺼내주는 연산자다. -
예를 들어
keyof { id: number; name: string }은"id" | "name"이라는 유니온 타입이 된다.type User = { id: number; name: string; age: number }; type UserKey = keyof User; // "id" | "name" | "age" -
K extends keyof T로 제약을 걸면,K가 반드시T의 실제 키 중 하나여야 한다고 강제할 수 있다.// K는 T의 키 중 하나여야 하고, 반환 타입은 그 키에 대응하는 값의 타입이다 function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] { return obj[key]; } const user = { id: 1, name: "Alice", age: 30 }; const n = getProperty(user, "name"); // n: string const a = getProperty(user, "age"); // a: number getProperty(user, "email"); // 오류: "email"은 user의 키가 아니다 -
반환 타입
T[K]는 인덱스 접근 타입으로, "T라는 객체 타입에서 K라는 키에 해당하는 값의 타입"을 의미한다. -
그래서
"name"을 넘기면string이,"age"를 넘기면number가 정확히 추론된다. -
이 패턴은 객체를 동적으로 접근해야 하는 상황에서 타입 안전성을 잃지 않는 핵심 도구다.
-
any로 도배된obj[key]접근을 깔끔하게 대체할 수 있다.
정리
-
제네릭의 본질은 타입을 함수 인자처럼 전달받도록 추상화해, 실제 타입 결정을 사용 시점까지 미루는 것이다.
-
이로써
any처럼 타입 검사를 포기하지 않고도, 같은 로직을 여러 타입에 대해 재사용할 수 있다. -
유니온이 "여러 타입 중 하나"라는 가능성을 의미한다면, 제네릭은 "호출마다 결정되는 하나의 구체 타입"이라는 관계를 의미한다.
-
타입 추론이 동작하므로 대부분의 호출 지점에서는 꺾쇠를 직접 쓸 필요가 없다.
-
같은 타입 변수를 여러 자리에 쓰면 타입 간의 관계를 표현할 수 있고, 여러 타입 변수를 두면 서로 독립적인 타입을 다룰 수 있다.
-
자유도가 너무 높아 함수 내부에서 구조를 가정할 수 없을 때는
extends로 제약을 걸어 필요한 속성만 보장받는다. -
keyof와 결합하면 객체 접근까지 타입 안전하게 표현할 수 있어, 라이브러리 수준의 유틸리티에서 자주 등장하는 패턴이 된다.