# 제네릭(Generics)의 사전적 정의
제네릭은 C#, Java 등의 언어에서 재사용성이 높은 컴포넌트를 만들 때 자주 활용되는 특징입니다. 특히, 한가지 타입보다 여러 가지 타입에서 동작하는 컴포넌트를 생성하는데 사용됩니다.
# 제네릭의 한 줄 정의와 예시
제네릭이란 타입을 마치 함수의 파라미터처럼 사용하는 것을 의미합니다. 아래 코드를 보겠습니다.
function getText(text) {
return text;
}
위 함수는 text
라는 파라미터에 값을 넘겨 받아 text
를 반환해줍니다. hi
, 10
, true
등 어떤 값이 들어가더라도 그대로 반환합니다.
getText('hi'); // 'hi'
getText(10); // 10
getText(true); // true
이 관점에서 제네릭을 한번 살펴보겠습니다.
function getText<T>(text: T): T {
return text;
}
위 함수는 제네릭 기본 문법이 적용된 형태입니다. 이제 함수를 호출할 때 아래와 같이 함수 안에서 사용할 타입을 넘겨줄 수 있습니다.
getText<string>('hi');
getText<number>(10);
getText<boolean>(true);
위 코드 중 getText<string>('hi')
를 호출 했을 때 함수에서 제네릭이 어떻게 동작하는지 살펴보겠습니다.
function getText<string>(text: T): T {
return text;
}
먼저 위 함수에서 제네릭 타입이 <string>
이 되는 이유는 getText()
함수를 호출할 때 제네릭(함수에서 사용할 타입) 값으로 string
을 넘겼기 때문입니다.
getText<string>();
그리고 나서 함수의 인자로 hi
라는 값을 아래와 같이 넘기게 되면
getText<string>('hi');
getText
함수는 아래와 같이 타입을 정의한 것과 같습니다.
function getText<string>(text: string): string {
return text;
}
위 함수는 입력 값의 타입이 string
이면서 반환 값 타입도 string
이어야 합니다.
# 제네릭을 사용하는 이유
또 다른 예제를 살펴보겠습니다.
function logText(text: string): string {
return text;
}
위 코드는 인자를 하나 넘겨 받아 반환해주는 함수입니다. 마치 리눅스의 echo 명령어와 같은 역할을 하죠. 여기서 이 함수의 인자와 반환 값은 모두 string
으로 지정되어 있지만 만약 여러 가지 타입을 허용하고 싶다면 아래와 같이 any
를 사용할 수 있습니다.
function logText(text: any): any {
return text;
}
이렇게 타입을 바꾼다고 해서 함수의 동작에 문제가 생기진 않습니다. 다만, 함수의 인자로 어떤 타입이 들어갔고 어떤 값이 반환되는지는 알 수가 없습니다. 왜냐하면 any
라는 타입은 타입 검사를 하지 않기 때문입니다.
이러한 문제점을 해결할 수 있는 것이 제네릭입니다. 아래 코드를 보겠습니다.
function logText<T>(text: T): T {
return text;
}
먼저 함수의 이름 바로 뒤에 <T>
라는 코드를 추가했습니다. 그리고 함수의 인자와 반환 값에 모두 T
라는 타입을 추가합니다. 이렇게 되면 함수를 호출할 때 넘긴 타입에 대해 타입스크립트가 추정할 수 있게 됩니다. 따라서, 함수의 입력 값에 대한 타입과 출력 값에 대한 타입이 동일한지 검증할 수 있게 됩니다.
그리고 이렇게 선언한 함수는 아래와 같이 2가지 방법으로 호출할 수 있습니다.
// #1
const text = logText<string>("Hello Generic");
// #2
const text = logText("Hello Generic");
보통 두 번째 방법이 코드도 더 짧고 가독성이 좋기 때문에 흔하게 사용됩니다. 그렇지만 만약 복잡한 코드에서 두 번째 코드로 타입 추정이 되지 않는다면 첫 번째 방법을 사용하면 됩니다.
# 제네릭 타입 변수
앞에서 배운 내용으로 제네릭을 사용하기 시작하면 컴파일러에서 인자에 타입을 넣어달라는 경고를 보게 됩니다.
조금 전에 살펴본 코드를 다시 보겠습니다.
function logText<T>(text: T): T {
return text;
}
만약 여기서 함수의 인자로 받은 값의 length
를 확인하고 싶다면 어떻게 해야 할까요? 아마 아래와 같이 코드를 작성할 겁니다.
function logText<T>(text: T): T {
console.log(text.length); // Error: T doesn't have .length
return text;
}
위 코드를 변환하려고 하면 컴파일러에서 에러를 발생시킵니다. 왜냐하면 text
에 .length
가 있다는 단서는 어디에도 없기 때문이죠.
다시 위 제네릭 코드의 의미를 살펴보면 함수의 인자와 반환 값에 대한 타입을 정하진 않았지만, 입력 값으로 어떤 타입이 들어왔고 반환 값으로 어떤 타입이 나가는지 알 수 있습니다. 따라서, 함수의 인자와 반환 값 타입에 마치 any
를 지정한 것과 같은 동작을 한다는 것을 알 수 있죠. 그래서 설령 인자에 number
타입을 넘기더라도 에러가 나진 않습니다. 이러한 특성 때문에 현재 인자인 text
에 문자열이나 배열이 들어와도 아직은 컴파일러 입장에서 .length
를 허용할 순 없습니다. 왜냐하면 number
가 들어왔을 때는 .length
코드가 유효하지 않으니까요.
그래서 이런 경우에는 아래와 같이 제네릭에 타입을 줄 수가 있습니다.
function logText<T>(text: T[]): T[] {
console.log(text.length); // 제네릭 타입이 배열이기 때문에 `length`를 허용합니다.
return text;
}
위 코드가 기존의 제네릭 코드와 다른 점은 인자의 T[]
부분입니다. 이 제네릭 함수 코드는 일단 T
라는 변수 타입을 받고, 인자 값으로는 배열 형태의 T
를 받습니다. 예를 들면, 함수에 [1,2,3]
처럼 숫자로 이뤄진 배열을 받으면 반환 값으로 number
를 돌려주는 것이죠. 이런 방식으로 제네릭을 사용하면 꽤 유연한 방식으로 함수의 타입을 정의해줄 수 있습니다.
혹은 다음과 같이 좀 더 명시적으로 제네릭 타입을 선언할 수 있습니다.
function logText<T>(text: Array<T>): Array<T> {
console.log(text.length);
return text;
}
# 제네릭 타입
제네릭 인터페이스에 대해 알아보겠습니다. 아래의 두 코드는 같은 의미입니다.
function logText<T>(text: T): T {
return text;
}
// #1
let str: <T>(text: T) => T = logText;
// #2
let str: {<T>(text: T): T} = logText;
위와 같은 변형 방식으로 제네릭 인터페이스 코드를 다음과 같이 작성할 수 있습니다.
interface GenericLogTextFn {
<T>(text: T): T;
}
function logText<T>(text: T): T {
return text;
}
let myString: GenericLogTextFn = logText; // Okay
위 코드에서 만약 인터페이스에 인자 타입을 강조하고 싶다면 아래와 같이 변경할 수 있습니다.
interface GenericLogTextFn<T> {
(text: T): T;
}
function logText<T>(text: T): T {
return text;
}
let myString: GenericLogTextFn<string> = logText;
이와 같은 방식으로 제네릭 인터페이스 뿐만 아니라 클래스도 생성할 수 있습니다. 다만, 이넘(enum)과 네임스페이스(namespace)는 제네릭으로 생성할 수 없습니다.
# 제네릭 클래스
제네릭 클래스는 앞에서 살펴본 제네릭 인터페이스와 비슷합니다. 코드를 보겠습니다.
class GenericMath<T> {
pi: T;
sum: (x: T, y: T) => T;
}
let math = new GenericMath<number>();
제네릭 클래스를 선언할 때 클래스 이름 오른쪽에 <T>
를 붙여줍니다. 그리고 해당 클래스로 인스턴스를 생성할 때 타입에 어떤 값이 들어갈 지 지정하면 됩니다.
조금 전에 살펴본 인터페이스처럼 제네릭 클래스도 클래스 안에 정의된 속성들이 정해진 타입으로 잘 동작하게 보장할 수 있습니다.
WARNING
참고! Generic classes are only generic over their instance side rather than their static side, so when working with classes, static members can not use the class’s type parameter
# 제네릭 제약 조건
앞에서 제네릭 타입 변수에서 살펴본 내용 말고도 제네릭 함수에 어느 정도 타입 힌트를 줄 수 있는 방법이 있습니다. 잠시 이전 코드를 보겠습니다.
function logText<T>(text: T): T {
console.log(text.length); // Error: T doesn't have .length
return text;
}
인자의 타입에 선언한 T
는 아직 어떤 타입인지 구체적으로 정의하지 않았기 때문에 length
코드에서 오류가 납니다. 이럴 때 만약 해당 타입을 정의하지 않고도 length
속성 정도는 허용하려면 아래와 같이 작성합니다.
interface LengthWise {
length: number;
}
function logText<T extends LengthWise>(text: T): T {
console.log(text.length);
return text;
}
위와 같이 작성하게 되면 타입에 대한 강제는 아니지만 length
에 대해 동작하는 인자만 넘겨받을 수 있게 됩니다.
logText(10); // Error, 숫자 타입에는 `length`가 존재하지 않으므로 오류 발생
logText({ length: 0, value: 'hi' }); // `text.length` 코드는 객체의 속성 접근과 같이 동작하므로 오류 없음
# 객체의 속성을 제약하는 방법
두 객체를 비교할 때도 제네릭 제약 조건을 사용할 수 있습니다.
function getProperty<T, O extends keyof T>(obj: T, key: O) {
return obj[key];
}
let obj = { a: 1, b: 2, c: 3 };
getProperty(obj, "a"); // okay
getProperty(obj, "z"); // error: "z"는 "a", "b", "c" 속성에 해당하지 않습니다.
제네릭을 선언할 때 <O extends keyof T>
부분에서 첫 번째 인자로 받는 객체에 없는 속성들은 접근할 수 없게끔 제한하였습니다.
# 강의와 책으로 더 쉽게 배워요 🎁
누적 수강생 8천명이 선택한 타입스크립트 입문 - 시작부터 실전까지 온라인 강의로 더 쉽게 배워볼 수 있어요 😃
강의와 함께 쉽게 시작하는 타입스크립트를 읽어보시면 실무에서 타입스크립트 코드가 전혀 두렵지 않을 겁니다 😃