우리가 프로그래밍을 할 때 ‘변수’라는 저장소를 사용하는 이유는 데이터 값의 유연성을 위해서이다. 변수라는 단어는 변할 수 있는 것을 말하고 그 반대인 상수는 항상 고정된 것을 말한다.
이러한 개념으로 보면 이때까지 number[] 며 string이며 사용했던 타입은 항상 고정되어 절대 변하지 않는 타입을 사용해오고 있었다. 그리고 여기에 약간의 유연성을 가미한게 number | string | undefined 와 같은 유니온 타입이다.
하지만 이 프로그래밍 환경에서는 상황이 항상 고정되어 의도대로 흘러가지는 않는다. 늘 변할 수 있는 변수가 생기게 되는데 이때 타입을 직접적으로 고정된 값으로 명시하지 말고 ‘변수’를 통해서 언제든지 변할 수 있는 타입을 통해 보다 유연하게 코딩을 할 수 있는 장치가 필요한데 이것이 바로 제네릭(generic) 타입이다.
⇒ 이는 간단하게 말하자면 타입을 변수화 한 것이라고 할 수 있다.
다음 코드 예제는 타입스크립트에서 왜 제네릭 타입이 빠져서는 안되는 요소인지 간단하게 알아보자.
add() 라는 메소드를 만드는데 이 함수는 숫자도 더해줘서 정수로 만들어주고 문자열도 더해줘서 합쳐진 문자열을 만들어주는 다재다능한 멀티 메소드이다. 넘버와 스트링 두 타입을 동시에 다뤄 유니온 타입을 통해 다음과 같이 구성할 수 있다.
function add(x: string | number, y: string | number): string | number {
return x + y;
}
add(1, 2); // 3
add('hello', 'world'); // 'helloworld'
언뜻봐도 이 함수에는 몇가지 함정이 있다. 인자로 넘겨받는 두 개의 타입이 각각 다를 경우엔 합 계산이 TypeScript 형식과 어긋나기 때문에 똑똑한 TypeScript는 바로 에러 경고를 내뿜는다.
단순한 해결방법으로는 함수 오버로딩이 있다.
function add(x: string, y: string): string;
function add(x: number, y: number): number;
function add(x: any, y: any) {
return x + y;
}
// 오버로딩을 통해 다음 함수 호출은 일어날 수가 없다.
// add(1, '2');
// add('1', 2);
이러한 한계 때문에 나온 것이 바로 제네릭이다.
다음과 같이 꺾쇠 괄호와 대문자 T 변수로서 지정함으로서, 제네릭을 통해 코드에 선언한 타입을 변수화 하고, 나중에 타입을 정하는 식으로 유연하게 사용이 가능하다.
// 제네릭: 꺾쇠와 문자 T를 이용해 표현, T는 변수명이라고 보면 된다.
function add<T>(x: T, y: T): T {
return x + y;
}
add<number>(1, 2); // 제네릭 타입 함수를 호출할 때 <number>라고 정해주면, 함수의 T부분이 number로 바뀌며 실행되게 된다.
add<string>('hello', 'world');
위 코드에서 <number>로 지정한 add에 커서를 갖다대면 변수 T 부분에 number가 들어간 걸 볼 수 있다.
이처럼 Generic 타입은 함수나 클래스의 선언 시점이 아닌, 사용 시점에 타입을 선언할 수 있는 방법을 제공한다.
선언할 때 그냥 변수 문자만 적어주고, 생성하는 시점에 사용하는 타입을 결정함으로써 변수나 함수 인터페이스를 다양한 타입으로 재사용할 수 있는 원리이다.
제네릭의 특징을 열거하면 다음과 같다.
타입이 고정되는 것을 방지하고 재사용 가능한 요소를 선언할 수 있다.
타입 검사를 컴파일 시간에 진행함으로써 타입 안정성을 보장
캐스팅 관련 코드를 제거할 수 있다.(이거는 지금 상태에선 다소 띠용이네?)
제네릭 로직을 이용해 타입을 다르게 받을 수 있는 재사용 코드를 만들 수 있다.
제네릭은 원래 C#, Java 등의 언어에서 재사용성이 높은 컴포넌트를 만들 때 자주 활용되는 타입이다. 특히, 한 가지 타입보다 여러 가지 타입에서 동작하는 컴포넌트를 생성하는데 사용된다. (실제로 타입스크립트 라이브러리의 메소드 타입 형태를 보면 모두 제네릭으로 이루어져 있다.)
특히 제네릭은 인터페이스와 정말 많이 쓰인다.
// 제네릭 인터페이스
interface Mobile<T> {
name: string;
price: number;
option: T; // 제네릭 타입 - option 속성에는 다양한 데이터 자료가 들어온다고 가정
}
// 제네릭 자체에 리터럴 객체 타입도 할당 할 수 있다.
const m1: Mobile<{ color: string; coupon: boolean }> = {
name: 's21',
price: 1000,
option: { color: 'read', coupon: false }, // 제네릭 타입에 의해서 option 속성이 유연하게 타입이 할당됨
};
const m2: Mobile<string> = {
name: 's20',
price: 900,
option: 'good', // 제네릭 타입에 의해서 option 속성이 유연하게 타입이 할당됨
};
또한 type alias 로도 잘 어울린다.
type TG<T> = T[] | T;
const number_arr: TG<number> = [1, 2, 3, 4, 5];
const number_arr2: TG<number> = 12345;
const string_arr: TG<string> = ['1', '2', '3', '4', '5'];
const string_arr2: TG<string> = '12345';
제네릭은 사용하는 시점에 타입을 결정해줌으로써 사실상 아무 타입이나 집어넣어도 상관 없다.
function identity<T>(p1: T): T {
return p1;
}
identity(1);
identity('a');
identity(true);
identity([]);
identity({});
이렇게 입력값에 대한 유연성을 확보했지만 각 함수에 대해 사용처에 따라서 입력값을 제한 할 필요가 생긴다. 가장 대표적인 예시로 forEach() 라는 메소드 제네릭을 이용하여 만든다고 쳤을 때, 이 forEach() 는 배열을 순회하는 고차 함수니 반드시 원본값을 배열로 받을 필요가 있다.
이처럼 타입스크립트의 제네릭은 적용되는 타입의 종류를 제한할 수 있는 기능을 제공한다.
다음과 같이 제네릭에 extends 키워드를 이용하면 제네릭 타입으로 입력할 수 있는 타입의 종류를 제한할 수 있다.
제네릭의 extends는 인터페이스나 클래스의 extends와는 약간 정의가 다르다.
클래스의 extends는 상속의 의미로서 ‘확장’의 정의를 가지지만, 제네릭의 extends는 ‘제한’의 의미를 가진다는 차이점이 있다.
따라서 형태의 제네릭이 있다면, T가 K에 할당 가능해야 한다 라고 정의하면 된다.
type numOrStr = number | string;
// 제네릭에 적용될 타입에 number | string 만을 허용
function identity<T extends numOrStr>(p1: T): T {
return p1;
}
identity(1);
identity('a');
identity(true); // ! ERROR
identity([]); // ! ERROR
identity({}); // ! ERROR
단순히 사용성을 위해 제네릭 타입을 제한 하는 것 뿐만 아니라 로직에 의해서 어쩔수 없이 제한해야 하는 경우도 있다. 예를 들어 다음 코드를 보면, T에는 .length 프로퍼티가 없다고 오류가 뜨는데, 왜냐하면 우리 입장에선 제네릭 타입이니 그럴려니 하겠지만 컴파일러 입장에선 T 타입이 대체 무엇인지 모르기 때문에 그런 것이다.
function loggingIdentity<T>(arg: T): T {
console.log(arg.length); // <---- arg가 string일지 아닐지 모르기 때문!!
return arg;
}
function loggingIdentity<T>(arg: T): T {
if (typeof arg === "string" || Array.isArray(arg))
console.log(arg.length);
return arg;
}
interface Lengthwise {
length: number;
}
// 제네릭 T 는 반드시 { length: number } 프로퍼티 타입을 포함해 있어야 한다.
function loggingIdentity<T extends Lengthwise>(arg: T): T {
console.log(arg.length); // 이제 .length 프로퍼티가 있는 것을 알기 때문에 더 이상 오류가 발생하지 않습니다.
return arg;
}
loggingIdentity(3); // 오류, number는 .length 프로퍼티가 없습니다.
loggingIdentity({ length: 10, value: 3 });하나의 함수에서 제네릭은 여러개 지정해서 사용할 수 있다.
이를 이용해 각 매개변수마다 다른 제네릭 타입 조건 제한을 걸 수 있다.
function myfunc<T extends string, K extends number>(arg1: T, arg2: K): void {
console.log(typeof arg1); // string
console.log(typeof arg2); // number
}
myfunc('1', 2);
이를 응용하면 다음과 같이 로직을 짤 수 있다.
getProperty라는 메소드가 있고, 이 함수는 객체와 key이름을 매개변수로 받는데, 만약 객체에 존재하지 않는 key명을 입력받을 경우 오류를 내뿜는다.
조건 분기로 해결할수도 있겠지만 제네릭 자체에서 타입을 제한하면 된다.
여기서 핵심은 K extends keyof T 제네릭 타입인데, 제네릭 T에는 x변수(객체)가 오게되는데 이 객체의 key값만 뽑아 keyof를 통해 유니온 타입으로 ‘a’ | ‘b’ | ‘c’ | ‘d’ 만들어주고 K 제네릭에 제한을 건다.
그러면 K 제네릭은 반드시 ‘a’ | ‘b’ | ‘c’ | ‘d’ 상수 타입만 올 수 있다. 이런식으로 타입 가드 장치를 거는것도 타입스크립트의 로직의 한 방법이다.
function getProperty<T, K extends keyof T>(obj: T, key: K) {
return obj[key];
}
let x = { a: 1, b: 2, c: 3, d: 4 };
getProperty(x, 'a'); // 성공
getProperty(x, 'm'); // 오류: 인수의 타입 'm'은 a, b, c, d에 들어가지 않는다.