🖥️

7. 유연하게 타입을 지정하려면 제네릭!

7.1 제네릭이란?

7.1.1 제네릭 소개

7.1.1.1 제네릭 정의

🔹
타입스크립트를 사용하다보면, 선언시에 타입을 지정하기가 어려운 경우가 있다.
제네릭은 컴포넌트 또는 함수에서 사용하는 데이터의 타입을 외부에서 지정하는 것을 의미한다. 어떤 타입의 데이터를 사용할 지를 선언 시점이 아니라, 생성 시점에 타입을 명시하여 하나의 타입만이 아닌 다양한 타입을 사용할 수 있도록 하는 기법이다. 한번의 선언으로 다양한 타입에 재사용이 가능하다는 장점이 있다.
타입을 하나의 변수처럼 취급하여 유연한 처리가 가능하게 한 것이 제네릭이라 할 수 있다.
 

7.1.1.2 제네릭 기본

제네릭은 아래와 같은 방법으로 선언하여 사용한다. 함수, 클래스, 인터페이스 등 이름 뒤에 <T>를 선언하고, 파라미터나 멤버에 T로 타입을 명시한다. T는 ‘Template’ 의 약자로 어떤 타입이든 들어올 수 있다. 제네릭명은 관습적으로 대문자 알파벳(T, U, K …)을 사용하는 편이다.
 
  • 함수 선언에서의 사용
//파라미터와 반환값의 타입을 지정 function lion <T> (x: T): T { return x }
 
  • 클래스 선언에서의 사용
class Lion <T> { name : string; age : number; property : T; }
 
  • 인터페이스 선언에서의 사용
interface Lion <T>{ name : string; age : number; property : T; }
 
  • 정리
// 선언할 때 이름 뒤에 제네릭이 온다고 생각하면 된다. function example <T> () {} class Example <T> () {} interface Example <T> {} type Example <T> = {}; const example = <T> () => {};
 
<T>에 어떠한 타입이든 다 들어올 수 있다고 하였다. 그렇다면 ‘허용범위가 넓어서 타입을 엄격하게 제한하는 타입스크립트의 사용 목적에 어긋나지는 않을까와 같은 의문이 생길 수 있을 것이다. 그 부분은 아래의 7.3 제네릭 타입 제한(제약 조건)을 통해 알아볼 예정이다
 

7.1.2 제네릭을 사용하는 이유

정적 타입 언어에서는 함수 및 컴포넌트를 생성하는 시점에 매개변수 또는 반환 타입을 정의해주어야 한다. 따라서 특정 타입을 위해 만들어진 클래스나 함수는 다른 타입을 위해 재사용 할 수 없다.
타입스크립트 역시 정적 타입 언어이기 때문에, 제네릭을 사용하지 않는다면 타입을 정의한 함수 혹은 클래스는 모두 다른 타입에 재사용이 불가하고, 새로 정의해주어야 할 것이다.
이처럼 같은 함수인데 여러가지 타입을 사용하여야 할 경우, 제네릭을 통해 타입을 변수처럼 취급해서 처리 가능하게 하여, 범용적인 사용을 가능하게 해준다. 제네릭을 활용하여, 컴포넌트와 함수의 재사용성을 증가시킬 수 있는 것이다.
 
  • 제네릭을 사용하지 않았을 경우, 모든 함수의 타입을 미리 지정해주어야 한다.
function exampleNumber(output : number) : number { return output; } function exampleString(output : string) : string { return output; } . . .
  • 제네릭을 활용하여 변수처럼 취급
function exampleGeneric <T> (output : T ) : T { return output; }
 
이전에 언급한 any를 사용해도 구현이 가능하다. 하지만, 제네릭과 차이점이 있는데, 이는 7.1.3 제네릭과 any의 차이에서 구체적으로 알아보자.
 
  • any를 사용한 경우
function exampleAny(output : any) : any { return output; }
 
다른 예시를 통해서, 제네릭의 용도를 계속해서 살펴보자.
 
  • 예시1
// 잘못된 함수 function sum(a : string | number, b : string | number) : string | number { return a + b } // 기댓값 sum(20, 30); // 50 sum ('20', '30'); // '2030'
예시1의 sum 함수는 유니온타입을 통해 ab, 반환값의 타입을 stringnumber로 지정하여, 반환값을 위의 결과와 같이 받고 싶어하는 함수이다. 그러나 위의 함수는 선언부에서 이미 잘못된 함수이다. 타입스크립트는 타입 추론에서 모든 경우의 수를 엄격하게 고려하여, 유니온타입 연산시 오류가 발생하기 때문이다.
  • 예시2
sum('20', 30); // '2030' sum(20, '30'); // '2030'
또한 예시2와 같은 인자에 대한 예외를 설정해주어야 한다.
전자의 경우를 허용하고, 후자의 경우를 배제하기 위해서는 어떻게 해야할까?
 
function sum(a : string, b : string) : string {return a + b} function sum(a : number, b : number) : number {return a + b}
위와 같이 진행할 수 있다면 좋겠지만, 함수를 2번 선언했기 때문에 에러가 발생한다.
 
  • 예시3
function sum <T> (a : T, b : T) : T {return a + b};
예시3과 같이 제네릭을 사용해주면 예시1, 예시2과 같은 문제를 해결할 수 있다.
 

7.1.3 제네릭과 any의 차이

타입을 자유롭게 사용하고 싶다면 any를 사용하면 될텐데, 왜 제네릭을 사용해야 하는 것일까? 그 이유에 대해서 더 자세히 살펴보고자 한다.
타입스크립트에서 any는 어떤 타입이든 허락한다는 의미로 해석할 수 있다. 타입을 검사하고 처리하기 위해 타입스크립트를 사용하는데, any타입이 생기는 것은 타입 추론 및 검사를 포기한다는 말과도 같다.
 
  • 타입을 넘겨줘도 any타입으로 추론해서 반환한다.
    • notion imagenotion image
타입에 any를 넣을 경우 타입스크립트를 사용하는 목적과는 달리, 모든 타입이 허용되는 결과가 나온다.
any를 사용한다면 자료의 타입을 제한할 수 없을 뿐더러, 이 함수를 통해 어떤 타입의 데이터가 반환되는지 알 수 없다. 타입을 체크하지 않으므로 메서드 힌트를 사용할 수 없고, 컴파일시에 오류를 찾지 못한다.
 
  • 제네릭을 사용할 경우, 제네릭 타입이 선언되는 순간 해당 타입이 고정되어 변할 수 없는 값이 된다.
notion imagenotion image
notion imagenotion image
 
  • 타입스크립트에서 추론한 타입과 관련해서 메서드 힌트를 보여주고 있다.
notion imagenotion image
notion imagenotion image
 
  • 이와 같이 생성시점에 타입을 지정해줄 수 있다.
notion imagenotion image
notion imagenotion image
 
제네릭 사용시 타입 파라미터의 위치에 주의하여야 한다.
// as로 바꾸어 타입을 강제 지정. 타입 단언. <number> lion (a, b); // 제네릭 lion <number> (a, b);
 

7.2 제네릭 타입

7.2.1 제네릭 인터페이스

 
  • 제네릭 인터페이스
// 제네릭 인터페이스 interface GenericInterface <T> { (x : T) : T; } function printX <T> (x : T) : T { return x; } let outputOfX : GenericInterface <number> = printX; console.log(printX(20)); // 20
제네릭 인터페이스로, 인터페이스 적용 시에 타입을 명시해주었다.
 
  • 인터페이스의 내부에서 제네릭 선언
// 인터페이스의 내부에서 제네릭 선언 interface GenericInterface <T> { <T> (x : T) : T; } function printX <T> (x : T) : T { return x; } let outputOfX : GenericInterface <number> = printX; console.log(printX<string>('20')); // '20'
구성요소가 제네릭으로, 함수 호출 시에 타입을 명시해주었다.
 
  • 예시1
interface Student<T> { name : string; age : number; property : T; //property에 다양한 타입의 데이터가 들어올 수 있음 } const student1 : Student<{ grade : string; attendance : boolean}> = { name : '김도영', age : 29, property : { grade : 'A', attendance : true }, // 유연한 할당 }; const student2 : Student<{looks : string}> = { name : '박시우', age : 25, property : {looks : 'handsome'}, // 유연한 할당 };
 

7.2.2 제네릭 함수

7.2.2.1 기본적인 함수에서의 사용

제네릭을 사용해 함수를 정의하는 가장 기본적인 방법은 다음과 같다.
// 배열을 하나 받아 순서를 뒤집어 리턴하는 함수 function reverseArr<T>(arr: T[]): T[] { let result = []; for (let i = arr.length - 1; i >= 0; i--) { result.push(arr[i]); } return result; }
함수_이름<타입_매개변수>(파라미터: 파라미터의_타입): 리턴값의_타입 순서로 작성하면 파라미터, 리턴값의 타입을 정할 수 있다. 이 때 꺾쇠(<>) 안에 넣는 T 를 제네릭 타입 변수 혹은 타입 매개변수라고 부른다.
 
이렇게 선언한 함수는 아래처럼 사용할수 있다.
console.log(reverseArr<number>([1, 2, 3])); // [3, 2, 1]
 
그러나 제네릭 함수에 매개변수가 주어진다면 굳이 타입을 명시하지 않아도 매개변수의 타입을 활용해 자동으로 유추된다.
console.log(reverseArr([1, 2, 3])); // <number>를 제거해도 무방하다.
 
프리뷰를 보면 인터프리터가 자동으로 유추한 타입을 확인할 수 있다.
notion imagenotion image
 

7.2.2.2 특정 객체 타입에 대응하기

interface Icecream { price: number; flavor: string; } interface Bread { price: number; name: string; } interface Coffee { beanType: string; }
이렇게 세 가지의 인터페이스를 선언한 상황에서 상품의 가격을 리턴하는 getPrice() 함수를 작성해보겠다. 상품의 타입은 Icecream, Bread, Coffee 등 다양하므로 제네릭 타입을 사용해 함수를 작성해야 한다.
 
function getPrice<T>(item: T) { return item.price + " 원"; }
그러나, 이 상태에서 Tprice 라는 프로퍼티가 있는지 여부는 알 수 없다. 따라서 아래와 같은 오류가 발생한다.
 
notion imagenotion image
이 경우 우리는 extends 키워드를 사용해 제네릭 타입 변수 T 를 확장, price 프로퍼티를 가지고 있음을 명시할 수 있다.
 
function getPrice<T extends { price: number }>(item: T) { return item.price + " 원"; }
이제 Icecream, Bread, Coffee 세가지 타입의 객체를 선언하고, getPrice() 에 파라미터로 넣어 사용해보자.
 
const chocoIcecream: Icecream = { price: 2800, flavor: "초코맛" } const custardCreamBread: Bread = { price: 100000, name: "커스타드 빵" } const cappuccinoCoffee: Coffee = { beanType: "케냐" } console.log(getPrice(chocoIcecream)); // 2800 원 console.log(getPrice(custardCreamBread)); // 100000 원 console.log(getPrice(cappuccinoCoffee)); // undefined 원, 타입스크립트 오류
IcecreamBread 인터페이스는 price 프로퍼티를 가지고 있으므로 chocoIcecreamcustardCreamBreadgetPrice() 정의에 사용된 제네릭 타입 T extends { price: number } 에 부합해 오류가 발생하지 않는다. 그러나, Coffee 에는 price 프로퍼티가 없으므로, cappuccinoCoffeegetPrice() 에 아규먼트로 넣은 곳에 다음과 같은 오류가 발생한다.
 
notion imagenotion image
해석하면 Coffeeprice 프로퍼티를 가지고 있지 않아 { price: number; } 타입에 할당할 수 없다는 의미이다.
 

7.2.3 제네릭 클래스

클래스에도 제네릭 타입 변수를 사용할 수 있다. 다음의 예제를 보자.
 
class Bread<T> { name!: T; price!: T; getChange!: (price: T) => number; } let custardCream = new Bread<string>(); custardCream.name = "커스타드"; custardCream.price = "100000"; custardCream.getChange = (paidMoney) => parseInt(paidMoney) - parseInt(custardCream.price); // [1] console.log(custardCream.getChange("120000")) // 20000
이처럼 클래스에 제네릭을 사용한 경우 인스턴스 생성시 타입을 지정할 수 있다.
[1] 에서 getDiscountedPrice() 메서드의 price 매개변수 타입이 string 이 됐다는 점에 주목하자.
notion imagenotion image
 

7.2.4. 더 나아가기

7.2.4.1 열거형과 네임스페이스에서의 제네릭

  • 열거형(enum)과 네임스페이스(namespace)는 제네릭 타입을 사용할 수 없다.
 

7.2.4.2 화살표 함수에서의 제네릭 사용

.tsx 란? 화살표 함수에서 제네릭을 사용하는 방법을 알아보기에 앞서서, .tsx 확장자 파일이 무엇인지 이해가 필요하다. 일반적인 .ts 와 똑같은데, JSX 도 포함하고 있는 파일이기에 꺾쇠(<>)의 사용에 있어 약간의 제약이 발생한다. JSX가 무엇인지는 이 책의 범위를 넘어가는 내용이기에 여기에서는 다루지 않는다.
 
앞서 정의했던 reverse() 함수를 이번에는 화살표 함수로 표현해보도록 하자.
 
const reverseArr = <T>(arr: T[]): T[] => { // ... 코드 생략 return result; }
이렇게 작성할 경우 .ts 확장자를 가진 파일에서는 정상적으로 동작하지만 .tsx 확장자 파일에서는 아래와 같이 코드를 제대로 인식하지 못한다.
 
notion imagenotion image
제네릭 타입을 나타내는 <T> 부분의 오류를 보면 왜 이런 일이 벌어지는지 짐작할 수 있다.
 
notion imagenotion image
즉, <T> 가 제네릭 타입 변수가 아닌 JSX 요소로 인식돼 생기는 문제이다. 따라서 제네릭 화살표 함수는 아래처럼 제네릭 타입에 extends 키워드를 빈 객체와 사용하여 꺾쇠가 JSX가 아닌 제네릭 타입을 나타내기 위한 용도임을 명시할 필요가 있다. 이 때 T 는 빈 객체 타입과 교차 타입을 이루므로 T 가 그대로 유지된다.
 
const reverseArr = <T extends {}>(arr: T[]): T[] => { // ... 코드 생략 return result; }
또는, 아래의 예시처럼 unknown 을 활용할 수도 있다.
 
const reverseArr = <T extends unknown>(arr: T[]): T[] => { // ... 코드 생략 return result; }
 

7.3 제네릭 타입 제한(제약 조건)

👉
제네릭 제약 조건 extends 키워드를 이용해 제네릭의 제약 조건을 설정하는 예시를 위에서 몇가지 보았다. 조금 더 알아보자.

7.3.1 프로퍼티 제약

제네릭을 이용해 객체의 속성을 검사할수 있다. 다음의 예제를 보자.
앞서 살펴본 예제에서 우리는 T 타입 변수가 price: number 프로퍼티를 가지고 있는지 여부를 확인했다. 이렇게 객체가 특정 키를 가지고 있는지 뿐만 아니라, 값이 어떤 타입인지를 추가로 확인하는 제약 조건을 달 수 있다. 덧붙여 아래의 예제를 보자.
 
function getLength<T extends { length: number }>(obj: T) { return obj.length; }
객체의 길이를 반환하는 함수 getLength() 이다. 이처럼 개발자가 직접 정의한 객체의 프로퍼티가 아닌 경우에도 속성 제약을 걸 수 있다.
 

7.3.2 콜백함수 제약 조건

function exchange<T extends (input: number) => number> (callback: T, input: number) : number { return callback(input); } console.log("1달러는 원화로 " + exchange((input: number) => input * 1400, 1) + "원 입니다.")
위 예제는 환전 로직을 담은 콜백함수와 값 하나를 인자로 받아 환전 결과를 돌려주는 함수이다. 이처럼 매개변수로서 함수를 전할 때 함수의 모양에도 제약을 걸 수 있다.
 

7.3.3 keyof 제약 조건

function getValue<T, U extends keyof T>(obj: T, key: U) { return obj[key]; } const obj = { name: "객체이지롱" }; console.log(getValue(obj, name)); // 객체이지롱
<T, U extends keyof T> 부분을 눈여겨 보자. U 타입 변수는 keyof T 이므로 UT 타입 변수의 키인지 여부를 확인하고 있다. 이처럼 keyof 키워드로 객체의 키 값을 활용한 제약조건을 걸 수 있다.