Typescript Generic

rada·2022년 3월 28일
0

개발

목록 보기
9/14

제네릭(Generic)은 직역하자면 '일반적인'이라는 뜻이다.
C#, Java등의 언어에서 재사용성이 높은 컴포넌트를 만들 때 자주 활용되는 특징이다.
특히, 한가지 타입보다 여러 가지 타입에서 동작하는 컴포넌트를 생성하는데 사용된다.

제네릭의 한 줄 정의와 예시

제네릭이란 타입을 마치 함수의 파라미터처럼 사용하는 것을 의미한다.

function getText(text){
	return text;
}

위 함수는 text라는 파라미터에 값을 넘겨 받아 text를 반환해준다. hi, 10, true등 어떤 값이 들어가더라도 그대로 반환한다.

getText('hi');
getText(10);
getText(true);

이 관점에서 제네릭을 한번 살펴보자

function getText<T>(text: T): T {
	return text;
}

위 함수는 제네릭 기본 문법이 적용된 형태다. 이제 함수를 호출할 때 아래와 같이 함수 안에서 사용할 타입을 넘겨줄 수 있다.

getText<string>(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가지 방법으로 호출할 수 있다.

const text = logText<string>('Hello Generic');
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;
}

Generic Type

제네릭 인터페이스에 대해 알아보겠다.

function logText<T>(text: T): T{
	return text;
}
let str: <T>(text: T) => T = logText; //#1
let str: {<T>(text: T): T} = logText; //#2

위 같은 변형 방식으로 제네릭 인터페이스 코드를 다음과 같이 작성할 수 있다.

interface GenericLogTextFn{
	<T>(text: T): T;
}
function logText<T>(text: T): T{
	return text;
}
let myString: GenericLogTextFn = logText;

위 코드에서 만약 인터페이스에 인자 타입을 강조하고 싶다면 아래와 같이 변경할 수 있다.

interface GenericTlogTextFn<T>{
	(text: T): T;
}
function logText<T>(text: T): T{
	return text;
}
let myString: GenericLogTextFn<string> = logText;

이와 같은 방식으로 제네릭 인터페이스 뿐만 아니라 클래스도 생성할 수 있다. 다만, 이넘(enum)과 네임스페이스(namespace)는 제네릭으로 생성할 수 없다.

Generic Class

제네릭 클래스는 앞에서 살펴본 제네릭 인터페이스와 비슷하다.

class GenericMath<T>{
	pt: 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 memebers can not 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 LegnthWise>(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');
getProperty(obj, 'z'); //error: 'z'는 'a','b','c'속성에 해당하지 않는다.

제네릭을 선언할 때 <O extends keyof T>부분에서 첫 번째 인자로 받는 객체에 없는 속성들은 접근할 수 없게끔 제한하였다.

profile
Surround yourself with people who inspire you to do the impossible

0개의 댓글