TypeScript - Generic 제네릭

2023. 4. 14. 22:29Programming Language/.Ts

시간 여유가 생겨서 공식 문서와 type-challenges 레포지토리를 통해 타입스크립트를 제대로 짚고 넘어가며 여기에 마저 정리해둬보려 한다. 마침 타입스크립트를 공부하다보면 여기저기, Generics, 제네릭이란 단어가 붙어있지만 제대로 이해하고 넘어가진 않았었다. Generics이란 무엇일까?

Generics, 제네릭

제네릭을 보통 <T>의 형태로 객체를 다루는 C++이나 Java등의 프로그래밍 언어 수업에서 본 적 있을 것이다.
제네릭(generic)이란 데이터의 타입(data type)을 일반화한다(generalize)는 것을 의미하며 보통은 제네릭을 통해 클래스나 메소드에서 사용할 내부 데이터 타입을 지정하고, 컴파일 단계에서 type check를 수행한다.
이런 개념이 적용된게 타입스크립트이고 다음과 같은 장점을 얻기 위해 사용한다.

사용 목적

 

  1. SW 엔지니어링에 있어 컴포넌트의 재사용과 함께, 잘 정의되고 일관된 API를 작성할 수 있다.

-> 이를 위해, 제네릭 타입을 사용하면 단일 타입이 아닌 다양한 타입에서 작동하는 컴포넌트를 사용할 수 있다.
2. 제네릭 타입으로 프로그래머는 여러 타입의 컴포넌트나 자신만의 타입을 정의해 사용할 수 있다. 그러므로 객체의 타입의 안정성을 높일 수 있다.
3. 리턴값에 대한 타입 변환 및 타입 검사에 들어가는 노력을 줄일 수 있다.

Hello Generics!

제네릭의 "hello world"격인 identity 함수를 작성해보자. identity 함수는, 전달되는 인자가 무엇이든간에 그대로 반환한다.

// 제네릭이 없을 경우, identity 함수 자체에 특정한 타입을 지정해주어야 합니다.
function identity(argument: number) : number {
    return argument;
}
// 또는 'any' 타입을 사용해서 identity 함수를 설명할 수 있습니다.
function identity(argument: any) : any {
    return argument; 
}

이때, any를 쓰게 되면, 제네릭처럼 argument의 타입이 무엇이든지 인자로 받을 수 있지만 any 타입을 리턴하면서 함수가 리턴하는 타입의 정보를 잃어버린다. 위 예시에서 만약 내가 number 타입이든 string 타입이든, 반환하는 argument의 타입은 any가 된다.

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

identity 함수에 <Type>이라는 타입 변수를 추가해보자. Type은 유저가 함수에 건네준 인자의 타입을 함수 스코프 내에서 사용하거나 반환할 수 있게 한다. 위 함수 예제에선 Type을 반환 타입으로 쓰고 있다. 이렇게 작성한 함수는, 제네릭으로 작동해 인자의 타입에 관계없이 작동하고 반환 타입의 정보도 잃지 않는다.

// 함수 사용 방법
// 1. Type 인자를 포함해 사용
let output = identity<string>("myString");
// 2. 컴파일러의 '타입 인수 추론'을 사용해 Type의 값을 정한다.
let output = identity("myString"); 

// 두가지 방법 모두 출력 타입은 'string'이다.  
// let output: string

Working with Generic Type Variables, 제네릭 타입 변수 작업

여기까지, 타입 변수를 이용해 스코프 내에서 타입 정보를 잃지 않는 identity 제네릭 함수를 만들었다.
이제 제네릭 함수를 사용하기 시작하면 타입스크립트의 컴파일러가 함수내에 제네릭 타입의 매개 변수만을 사용하도록 제한한다. 그래서 만일 아래와 같이 코드를 작성하면 컴파일 에러를 만날 수 있다.

function loggingIdentity<Type>(arg: Type): Type {
  console.log(arg.length);
// > error! Property 'length' does not exist on type 'Type'.
  return arg;
}

우리가 선언한 제네릭 타입인 Type.length 멤버가 명시되어 있지 않다. Type은 any나 모든 타입을 인자로 받기 때문에,.length 멤버가 없는 number를 전달하는 경우가 있을 수 있으므로 에러가 나타나는 것이다..length 멤버를 쓰고 싶다면, 이 Type이 배열을 인자로 받고 있다는 것을 보여주자.

// 1.
function loggingIdentity<Type>(arg: Type[]): Type[] {
  console.log(arg.length); 
  return arg;
}

// 2. 이렇게도 표현 가능하다.
function loggingIdentity<Type>(arg: Array<Type>): Array<Type> {
  console.log(arg.length); 
  return arg;
}

// 두 방법 모두, 인자로 배열을 받는다. 배열은 .length 멤버를 가지고 있다. 따라서 오류는 없다.

Generic Type, Generic Interface 제네릭 타입과 제네릭 인터페이스

그럼 이제 제네릭 타입은 무엇일까? 공식 문서에서 이 섹션부터는 제네릭 인터페이스와 함수 자체의 타입 생성을 다루고 있다. 이전 섹션에서 만든 제네릭 함수 identity를 다시 보자. identity 함수의 타입을 지정해보자. 함수의 타입을 지정할 땐, 함수 호출 시그니처(Call signature)를 사용한다. 함수 호출 시그니처 사용하면 제네릭 타입을 인터페이스를 사용해 지정하는 것도 가능하다. 아래 예제를 보자.

// 1. myIdentity는 Type 타입을 받아 Type 형을 리턴하는 함수로 identity 함수와 동일한 작동을 한다.
function identity<Type>(arg: Type): Type {
  return arg;
}
let myIdentity: <Type>(arg: Type) => Type = identity;
//2. 제네릭 타입을 객체 리터럴 타입으로 작성할 수 있다. 
function identity<Type>(arg: Type): Type {
  return arg;
}
let myIdentity: { <Type>(arg: Type): Type } = identity;
//3. 제네릭 타입을 객체 리터럴 인터페이스로 작성해 사용할 수 있다.
interface GenericIdentityFn {
  <Type>(arg: Type): Type;
}

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

let myIdentity: GenericIdentityFn = identity;
//4. 3번을 아래처럼, 제네릭 타입을 인터페이스의 매개변수로 전달해 사용할 수 있다.
interface GenericIdentityFn<Type> {
  (arg: Type): Type;
}

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

let myIdentity: GenericIdentityFn<number> = identity;

요약해서, identity 함수는 Type 타입의 argument를 받아 다시 Type 타입의 argument를 리턴한다.
myIdentity는 제네릭 함수인 identity 함수를 제네릭 인터페이스로 작성한 함수이다.

Generic Classes 제네릭 클래스

제네릭 인터페이스와 제네릭 클래스는 비슷한 형태로 이루어져있다. 또, 타입스크립트의 제네릭 클래스는 다른 언어에서 사용하던 클래스 선언과 형태가 비슷하다. 아래 예제를 보자.

class GenericNumber<NumType> {
  zeroValue: NumType;
  add: (x: NumType, y: NumType) => NumType;
}

let myGenericNumber = new GenericNumber<number>();
myGenericNumber.zeroValue = 0;
myGenericNumber.add = function (x, y) {
  return x + y;
};

위 예제는 제네릭 타입을 받는 GenericNumber<Numtype>클래스와 타입매개변수를 받아 새 class를 생성하는 myGenericNumber의 예제이다. 위 예제에선 number 타입을 받고 있지만, string 타입을 받도록 코드를 작성할 수도 있다.

let stringNumeric = new GenericNumber<string>();
stringNumeric.zeroValue = "";
stringNumeric.add = function (x, y) {
  return x + y;
};

console.log(stringNumeric.add(stringNumeric.zeroValue, "test"));
// [LOG]: test

Generic Constraints 제네릭 제약조건

특정 타입에만 혹은 특정한 타입 매개변수에서만 동작하도록 제네릭 함수를 만들고 싶은 경우에 사용한다. 예를 들어, 앞에서 제네릭 타입이 배열이 아닌 경우엔 .length property가 없으므로 컴파일 시 에러가 떴었다. 이를 제네릭 제약조건으로 해결해보자. 먼저 우리의 제약을 서술할 interface를 사용한다.

interface LengthProperty {
    length: number;
}

function Identity<Type extends LengthProperty>(arg: Type): Type {
  console.log(arg.length); 
  return arg;
}

클래스의 자식이 부모를 상속받듯, argument의 Typeextends 키워드로 LengthProperty 인터페이스를 확장할 수 있다. 이 제약조건을 통해 .length property가 확인되어 더이상 에러가 나지 않는다. 그러나 이렇게 제약조건을 걸게 되면 interface에 작성한 제약에 따라 인자에 필요한 property와 value를 전달해야 한다.

// loggingIdentity(3); // ! error Argument of type 'number' is not assignable to parameter of type 'Lengthwise'
loggingIdentity({ length: 10, value: 3 });

Using Type Parameters in Generic Constraints 제네릭 제약조건에서 타입 매개변수 사용

이미 선언되어있는 객체에서 property를 가져오고 싶을 때를 생각해보자. 타입스크립트에선 타입 매개변수 안에서 하나의 타입을 매개변수의 또 다른 타입으로 제약을 걸 수 있다.

function getProperty<Type, Key extends keyof Type>(obj: Type, key: Key) {
  return obj[key];
}

let x = { a: 1, b: 2, c: 3, d: 4 };

getProperty(x, "a");
getProperty(x, "m");
// Argument of type '"m"' is not assignable to parameter of type '"a" | "b" | "c" | "d"'.

위 코드를 보면, 객체 x안에 존재하지 않는 property인 "m"을 Key값에 전달한 결과, 컴파일 시 에러가 발생함을 알 수 있다.

타입스크립트의 빌트인인 Pick도 비슷하게 제네릭 제약 조건을 사용해 구현할 수 있다.

type MyPick<T, K extends keyof T> = {
  [P in K]: T[P];
};

Using Class Types in Generics 제네릭에서의 클래스 타입 참조

객체지향 프로그래밍처럼, class를 이용해 프로토타입을 만드는 예시를 살펴보자.

class BeeKeeper {
  hasMask: boolean = true;
}

class ZooKeeper {
  nametag: string = "Mikle";
}

class Animal {
  numLegs: number = 4;
}

class Bee extends Animal {
  keeper: BeeKeeper = new BeeKeeper();
}

class Lion extends Animal {
  keeper: ZooKeeper = new ZooKeeper();
}

function createInstance<A extends Animal>(c: new () => A): A {
  return new c();
}

createInstance(Lion).keeper.nametag;
createInstance(Bee).keeper.hasMask;