본문 바로가기
개발/타입스크립트

[타입스크립트] 제네릭에 대하여

by 핸디(Handy) 2021. 3. 15.

다음 코드를 한번 살펴보겠습니다.

들어온 number type의 arg를 그대로 돌려주는 함수가 있습니다.

function identity(arg : number) : number {
	return arg;
}

근데 number type 뿐만 아니라 string type이라면, 또는 boolean type이라면 어떻게 할까요?

function identityNum(arg : number) : number {
	return arg;
}

function identityString(arg : string) : string {
	return arg;
}

function identityBoolean(arg : boolean) : boolean {
	return arg;
}

이렇게 하나씩 구현을 하는 건 누가 봐도 멍청해 보입니다. 

이럴 때 사용하는 것이 바로 Generic(제네릭)입니다.


[ Generic ]

  = 간단히 말해서 들어온 타입을 받아 나가는 타입으로 그대로 반환하는 기법

위에 코드를 다시 보겠습니다.

function identity(arg : number) : number {
	return arg;
}


function identityAny(arg : any) : any {
	return arg;
}

const str = identity("test"); // <- Error 💣
const strG = identityAny("test"); // <- Ok ✔

그렇다면 모든 타입을 받을 수 있는 만능 타입 any는 어떨까요?

하지만 any타입으로 들어오게 되면 기본의 객체가 가지고 있는 타입을 잃어버리는 단점을 가지고 있습니다. 

그러니 이제 any 말고 Generic를 이용하여 구현을 해보겠습니다.

function identity<T>(arg: T): T {
    return arg;
}

//일반적인 호출 ( 타입 인수 추론을 사용한 호출 )
const str : string = identity("test");
const num : number = identity(100);

// 별개로 호출( 타입을 포함한 호출 )
let extra : string = identity<string>("extra");

구현은 아주 간단합니다. 꺾쇠(< >)로 타입을 받고 인자에도 타입을 주고 반환 값에도 타입을 주는 것입니다. 

function identity<Type>(arg: Type): Type {
    return arg;
}

또한 T 뿐만 아니라 Type 등 어떤 문자이든 가능하지만 일반적으로 대문자로 하나만 쓰는 것이 약속입니다.


이전까지 Generic의 기본적인 예시를 함수를 통해 살펴봤습니다. 

이제 이전 지식을 확장하여 Generic Class를 더 알아봅시다.

[ Generic Class ]

가장 기본적인 형태는 아래와 같습니다.

class Value<T> {
  value: T;
  getValue: () => T;
}

하지만 이번엔 하나가 아닌 여러 개의 타입을 받는 Generic Class를 구현해보겠습니다.

interface Company<N, P> {
  getName: () => N;
  getPrice: () => P;
  divide: () => void;
}

class TeslaCompany<N, P> implements Company<N, P> {
  name: N;
  price: P;
  constructor(name: N, price: P) {
    this.name = name;
    this.price = price;
  }
  getName(): N {
    return this.name;
  }
  getPrice(): P {
    return this.price;
  }
  divide(): void {
    console.log("배당을 했습니다");
  }
  getBestCar(): string {
    return "Model 3";
  }
}

class MSCompany<N, P> implements Company<N, P> {
  name: N;
  price: P;
  constructor(name: N, price: P) {
    this.name = name;
    this.price = price;
  }
  getName(): N {
    return this.name;
  }
  getPrice(): P {
    return this.price;
  }
  divide(): void {
    console.log("배당을 했습니다");
  }
  getBestPC(): string {
    return "Window 98";
  }
}

interface를 이용한 Type이 여러 개라면 interface를 활용하는 것이 좋은 타입 스크립트라고 배웠으므로 interface를 사용합니다.

보시면 아주 적절히 동작함을 확인할 수 있습니다.


이제 여기서 더 깊게 들어가 봅시다.

interface Company<N, P> {
  getName: () => N;
  getPrice: () => P;
  divide: () => void;
}

class TeslaCompany<N, P> implements Company<N, P> {
  name: N;
  price: P;
  bestCar: string = "Model 3";
  constructor(name: N, price: P) {
    this.name = name;
    this.price = price;
  }
  getName(): N {
    return this.name;
  }
  getPrice(): P {
    return this.price;
  }
  divide(): void {
    console.log("배당을 했습니다");
  }
}

class MSCompany<N, P> implements Company<N, P> {
  name: N;
  price: P;
  bestPC: string = "Window 98";
  constructor(name: N, price: P) {
    this.name = name;
    this.price = price;
  }
  getName(): N {
    return this.name;
  }
  getPrice(): P {
    return this.price;
  }
  divide(): void {
    console.log("배당을 했습니다");
  }
}


const tesla = new TeslaCompany("tesla", 100);
const ms = new MSCompany("MS", 200);

tesla.bestCar; // Model 3
ms.bestPC; // Window 98

보시다시피 Stock interface를 implements 하는 Tesla Class, MS Class 가 있습니다.

getName, getPrice, divide는 interface를 통해 강제로 구현했지만 

Tesla => bestCar ,  MS => bestPC를 각각 가지고 있는 Class 예시입니다.


[ Generic Constraint ]

company를 받아서 배당을 하도록 하는 로직이 divide 가 있습니다.

function divide<T>(company: T): T {
  company.divide(); // 💣Property 'divide' does not exist on type 'T'.
  return company;
}

해서 함수를 구현하고자 했는데 company.divide()가 나와버립니다. 이유는 런타임상에는 T가 결정되지만 정적분석하고 있는 현재 상황에선 T가 어떤 타입인지 모르기 때문입니다. 

타입을 모르는데 그다음에 divide()가 있다는 보장이 없기 때문에 타입 스크립트 정적분석기가 Error를 내뱉는 것입니다.

이것을 해결하는 방법은 2가지가 있습니다.

// 타입 assertion를 이용해서 강제로 타입을 맞춰주기
function divide<T>(company: T): T {
  (company as unknown as Company<string,number>).divide();
  return company;
}

// Generic constraint ( 제네릭 제약조건 )으로 명시해주기
function divideC<T extends Company<string, number>>(company: T): T {
  company.divide();
  return company;
}

방법은 2가지이지만 타입 assertion은 강제로 맞춰주는 것이기 때문에 사용을 지양해야 합니다.

따라서 Generic constraint를 이용하여 타입을 한정시키는 방법이 최선입니다.

또 다른 Generic constraint의 예제를 보겠습니다.

const apple = {
  price: 100,
  iPhone: "12S",
};

const samsung = {
  price: 100,
  galaxy: "S20",
};

다음과 같이 apple, samsung 객체가 있습니다. 

그리고 특정 함수를 통해 key를 넘기고 key에 해당하는 value를 얻는 getValue 함수가 있습니다.

// Javascript Style
function getValue(obj, key) {
  return obj[key];
}

getValue(apple, "iPhone"); // 12S;
getValue(samsung, "galaxy"); // S20;

getValue(samsung,"iPhone"); // undefined 💣💣

자바스크립트에서는 아주 간단히 끝이 났을 것입니다. 하지만 해당 obj, key는 다음과 같이 any 타입을 가지고 있습니다.

따라서 samsung에 iPhone를 입력해도 타입 스크립트 정적분 석기가 잡아주질 못합니다. 타입 그대로 any이기 때문에 무엇이든 올 수 있기 때문입니다. 

이렇듯 타입 스크립트에서는 타입을 지정해줘야만 의미가 있는 것이기에 타입을 제대로 넣어보겠습니다.

넣어봤는데 이상합니다. 그리고 고민이 듭니다. obj, key는 제네릭으로 받았어, 근데 return type은 뭐지? 

'아 obj [key]의 타입이면 되겠구나' 그리고 코드를 업데이트합니다.

또 오류를 확인해봅니다. 타입 K는 T타입의 인덱스를 쓸 수가 없다고 합니다.

 왜?? 앞서 말했듯이 아직 무슨 타입인지 모릅니다. K 가 T 안에 있다는 보장을 할 수가 없습니다.

보장을 할 수가 없다?? ==> Generic constraint입니다.

function getValue<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}

완벽해졌습니다. 정적분석기가 samsung 객체에는 galaxy와 price만 있다고 알려주고 있습니다.

우리는 이제 타입스크립트의 편리성을 되찾았습니다.

 

[ Result ]

코드 측면

타입스크립트에서 제네릭의 핵심은 여러 타입에 대한 요소를 정의하되, 해당 요소가 사용될때 비로소 알 수 있는 타입 정보를 정의하여 사용하는 것

개발자 측면

우리가 코드를 짜는 동안 (동적으로) 타입이 정의되면 정적인 타입스크립트 분석기가 해당 타입에 알맞은 다른 타입들을 제공하는 것

 


타입스크립트는 타입을 가지고 있을 때에 진정으로 의미가 있는 스크립트 언어입니다. 타입이 없다면 최신 자바스크립트에 불과합니다.

따라서 자바스크립트에서는 상대적으로 신경쓸 필요가 없었던 타입을 처리하는데 약간의 공수가 더 듭니다.

하지만 약간의 공수를 더한다면 아름다운 IDE들이 엄청난 메리트와 편리성을 제공해 줄 것입니다.

그래서 기본적인 타입에 관한 글은 다들 아시리라 생각하고 제네릭에 대해서 정리를 해보았습니다.

<추가자료>
ahnheejong.gitbook.io/ts-for-jsdev/03-basic-grammar/generics
typescript-kr.github.io/pages/generics.html

 

댓글