Javascript

TypeScript 제네릭 패턴과 활용법 - 클래스, 함수, 메서드 완벽 분석

드리프트2 2024. 5. 15. 21:31

 

 

 

 

제네릭이란?

Java에서는 제네릭스(Generics)라고 불리기도 합니다.

 

언어마다 이 용어의 표현이 다르죠.

 

TypeScript에서도 제네릭스나 제너릭스라고 불리기도 합니다.

 

제네릭은 "총칭형(総称型)"이라고도 불리며, 임의의 클래스나 메서드에 대해 임의의 타입을 연결할 수 있는 구조입니다.

 

자세한 내용은 여기여기에서 확인할 수 있습니다.

언제 유용할까?

배열에서 다루는 타입을 지정하고 싶을 때

예를 들어 배열 변수를 정의할 때, 이 배열이 특정 타입을 다룬다는 것을 알 수 있으면 좋습니다.

 

그리고 지정된 타입 이외의 값을 설정하려 할 때나, 지정된 타입에 없는 멤버를 설정하려 할 때 오류를 발생시켜주면 더욱 좋습니다.

 

이러한 기능을 제공해주는 것이 제네릭입니다.

 

예제 코드를 살펴봅시다.

// Array에서 제네릭 타입을 지정할 때 사용하는 인터페이스
interface IFoo {
  foo: string;
  bar: number;
  baz: boolean;
}

// IFoo 타입의 배열을 선언
// 아래 코드는 const aFoo: IFoo[]와 동일
const aFoo: Array<IFoo> = [];

// 이는 설정하려는 값의 타입이 다르므로 오류가 발생합니다.
// Argument of type 'number' is not assignable to parameter of type 'IFoo'.(2345)
aFoo.push(1);

// 이 경우에도 설정하려는 값의 타입이 다르므로 오류가 발생합니다.
// Argument of type 'string' is not assignable to parameter of type 'IFoo'.(2345)
aFoo.push("foo");

// 변수를 선언할 때 지정한 IFoo 타입에 맞는 데이터이므로 괜찮습니다.
aFoo.push({
  foo: "foo",
  bar: 100,
  baz: false,
});

 

이렇게 Array에서 다룰 타입을 지정할 수 있고, 다른 타입을 다루려고 하면 오류를 반환합니다.

 

참고로 IFoo 타입의 배열을 선언할 때 아래와 같은 방법으로도 작성할 수 있습니다.

// IFoo 타입의 배열 선언
const aFoo: IFoo[] = [];

함수나 메서드의 인수에 범용성을 부여하고 싶을 때

TypeScript에서는 메서드 오버로드도 지원하지만, Java나 C# 등의 오버로드와는 다릅니다.

 

다음 코드는 메서드 오버로드의 예시입니다.

/**
 * 메서드 인수의 타입이 다른 오버로드 구현 예시
 * 오버로드를 공용 타입으로 구현하고 있습니다.
 */

// 오버로드할 메서드 시그니처를 선언
interface IFoo {
  // 메서드 시그니처 부분
  foo(arg: string);
  foo(arg: string[]);
}

class CFoo implements IFoo {
  // 인수를 공용 타입으로 지정하여 범용성을 확보
  foo(arg: string | string[]) {
    console.log(arg);
  }
}

const cFoo = new CFoo();
cFoo.foo("foo");
cFoo.foo(["foo", "bar"]);

// Logs:
// foo
// ["foo", "bar"]

 

이 코드에서는 메서드의 실제 구현이 인수를 공용 타입으로 정의하고 있습니다.

 

다루고 싶은 타입이 늘어나면 그만큼 공용 타입에 추가해야 하므로 다소 복잡합니다.

 

이 복잡함을 제네릭을 사용하여 해결할 수 있습니다. 아래는 제네릭을 사용한 구현입니다.

/**
 * 메서드 인수의 타입이 다른 오버로드 구현 예시
 * 오버로드를 제네릭을 사용하여 구현함으로써 임의의 타입을 다룰 수 있습니다.
 */

// 오버로드할 메서드 시그니처를 선언
interface IFoo {
  // 메서드 시그니처 부분
  foo<T>(arg: T);
}

class CFoo implements IFoo {
  foo<T>(arg: T) {
    console.log(arg);
  }
}

const cFoo = new CFoo();
cFoo.foo<string>("foo");
cFoo.foo<number>(100);
cFoo.foo<{}>({ foo: "foobar" });
cFoo.foo<string[]>(["foo", "bar"]);

// Logs:
// foo
// 100
// { foo: "foobar" }
// ["foo", "bar"]

 

앞의 코드와 마찬가지로 이 구현 방법도 Java나 C#과 같은 오버로드와는 다소 차이가 있지만, 인수를 공용 타입으로 지정하는 것보다 인터페이스가 깔끔해졌습니다.

 

실제 업무에서 사용할 때는 구체적인 클래스의 역할과 전달되는 인수의 타입에 주의해야 하지만, 제네릭을 사용할 수 있는 상황을 상상할 수 있게 되었습니다.

보충 설명: 인수의 수가 다른 오버로드

위의 코드에서는 인수의 수가 같은 경우에만 대응할 수 있습니다.

 

인수의 수가 다른 오버로드에 대응하려면 제네릭을 사용하지 않는 구현 예시처럼 두 번째 인수 이후를 옵션으로 지정해야 합니다.

 

제네릭을 사용한 경우에도 동일하게 구현할 수 있습니다.

/**
 * 메서드 인수의 수가 다른 오버로드 구현 예시
 * 이 경우 제네릭을 사용한 구현에서도 두 번째 인수 이후를 옵션으로 지정해야 합니다.
 */

// 오버로드할 메서드 시그니처를 선언
interface IFoo {
  // 메서드 시그니처 부분
  foo(arg: string, arg2?: string, arg3?: string);
}

class CFoo implements IFoo {
  // 두 번째 인수 이후를 옵션으로 지정하여 범용성을 확보
  foo(arg: string, arg2?: string, arg3?: string) {
    console.log(arg);
    if (arg2) {
      console.log(`${arg}, ${arg2}`);
    }
    if (arg3) {
      console.log(`${arg2}, ${arg2}, ${arg3}`);
    }
  }
}

const cFoo = new CFoo();
cFoo.foo("foo");
cFoo.foo("foo", "bar");
cFoo.foo("foo", "bar", "baz");

제네릭을 클래스 타입으로

제네릭은 클래스 타입으로도 사용할 수 있습니다.

기본

제네릭을 클래스 타입으로 사용하려면 다음과 같이 클래스 이름 뒤에 <...>를 지정합니다.

/**
 * 임의의 타입을 다루는 클래스
 * 제네릭으로 지정된 타입은 해당 클래스에서 다루는 타입이 됩니다.
 */
class GFoo<T> {
  value: T;
  getValue(): T {
    return this.value;
  }
}

// string을 지정하여 인스턴스를 생성
// - 이를 통해 GFoo는 string을 다루는 인스턴스로 생성됨
// - GFoo의 필드인 value에는 string만 설정 가능
const gFoo = new GFoo<string>();
gFoo.value = 'Foo';

console.log(gFoo.getValue()); // Foo

// value에 string 이외의 값을 설정하면 오류 발생
//
// Type 'boolean' is not assignable to type 'string'.(2322)
// (property) GFoo<string>.value: string
gFoo.value = false;

// number도 마찬가지
//
// Type 'number' is not assignable to type 'string'.(2322)
// (property) GFoo<string>.value: string
gFoo.value = 1;

 

클래스에 대해 제네릭을 사용하여 타입을 지정하면, 해당 클래스의 인스턴스가 다루는 타입을 임의로 설정할 수 있음을 알 수 있습니다.

 

제네릭을 사용함으로써 인스턴스 생성 시 다룰 타입의 자유도가 높아졌을 뿐만 아니라 지정한 타입 이외의 값은 오류로 처리하는 엄격함도 얻을 수 있게 되었습니다.

응용: 여러 개의 타입 인수 지정

클래스에서 다룰 타입은 여러 개 지정할 수 있습니다.

 

방법은 타입 인수를 지정할 때 ,로 구분하는 것입니다.

/**
 * 타입 인수를 여러 개 지정할 수 있는 클래스
 */
class GFoo<T, R> {
  tValue: T;
  rValue: R;

  getTValue(): T {
    return this.tValue;
  }

  getRValue(): R {
    return this.rValue;
  }
}

// 타입 인수를 2개 받는 클래스이므로 인스턴스 생성 시에도 타입을 여러 개 지정
// 여기서는 string과 number를 다루는 인스턴스를 생성
const gFoo = new GFoo<string, number>();
gFoo.tValue = 'Foo';
gFoo.rValue = 100;

console.log({
  tValue: gFoo.getTValue(),
  rValue: gFoo.getRValue(),
});

// Logs:
// { tValue: "Foo", rValue: 100 }

타입 인수 기본값 설정

타입 인수에는 기본값을 설정할 수도 있습니다.

 

기본값을 지정하여 클래스를 정의한 경우:

  1. 타입을 지정하지 않고 인스턴스를 생성하기
  2. 타입을 지정하여 인스턴스를 생성하기

두 가지 방법으로 인스턴스를 생성할 수 있습니다.

 

전자의 경우 기본값 타입을 다루는 인스턴스가 생성되고, 후자의 경우 지정된 타입을 다루는 인스턴스가 생성됩니다.

 

물론 기본값 타입을 지정하여 인스턴스를 생성할 수도 있습니다.

/**
 * 타입 인수의 기본값을 지정하여 임의의 타입을 다루는 클래스
 */
class GFoo<T = string> {
  tValue: T;

  getTValue(): T {
    return this.tValue;
  }
}

///////////////////////////////////////////////////
// 타입 인수를 지정하지 않고 인스턴스를 생성하기
///////////////////////////////////////////////////

// 기본값으로 string을 지정했으므로 타입 인수 없이 인스턴스를 생성할 수 있음
const gFooDefault = new GFoo();
gFooDefault.tValue = 'Foo';

console.log({
  tValue: gFooDefault.getTValue(),
});

// Logs:
// { tValue: "Foo" }

// 이 상태에서 tValue에 number를 지정하면 오류가 발생
// Type 'number' is not assignable to type 'string'.(2322)
// const gFoo: GFoo<string>
gFooDefault.tValue = 100;

///////////////////////////////////////////////////
// 타입 인수를 지정하여 인스턴스를 생성하기
///////////////////////////////////////////////////

// 이번에는 타입 인수를 지정하여 인스턴스를 생성
const gFooNumber = new GFoo<number>();
gFooNumber.tValue = 100;

console.log({
  tValue: gFooNumber.getTValue(),
});

// Logs:
// { tValue: 100 }

// 타입 인수에 number를 지정해 인스턴스를 생성했으므로 string을 설정하려 하면 오류가 발생
// Type 'string' is not assignable to type 'number'.(2322)
// (property) GFoo<number>.tValue: number
gFooNumber.tValue = "Foo";

///////////////////////////////////////////////////
// 타입 인수 기본값과 동일한 타입을 지정하여 인스턴스를 생성하기
///////////////////////////////////////////////////

// 이번에는 타입 인수 기본값과 동일한 타입을 지정하여 인스턴스를 생성
const gFooString = new GFoo<string>();
gFooString.tValue = "Foo";

console.log({
  tValue: gFooString.getTValue(),
});

// Logs:
// { tValue: "Foo" }

// 타입 인수에 string을 지정해 인스턴스를 생성했으므로 number를 설정하려 하면 오류가 발생
// Type 'number' is not assignable to type 'string'.(2322)
// (property) GFoo<string>.tValue: string
gFooString.tValue = 100;

타입 인수에 제약을 두기

여기서 말하는 "제약"이란 특정 클래스를 상속한 클래스만 허용한다는 의미입니다.

 

인스턴스를 생성할 때 제네릭 타입 인수에는 임의의 타입을 지정할 수 있지만, 다룰 타입을 제한하고 싶은 경우가 있습니다.

 

그럴 때 유용한 방법을 소개합니다.

class BaseFoo {
  value: string;
}

class ChildFoo extends BaseFoo {
  value2: number;
}

/**
 * 제네릭으로 다룰 타입을 특정 클래스 또는 그 하위 클래스에 제한
 */
class GFoo<T extends BaseFoo> {
  tValue: T;

  getTValue(): T {
    return this.tValue;
  }
}

////////////////////////////////////////////////////
// 타입 인수에 하위 클래스를 지정하여 인스턴스를 생성하기
////////////////////////////////////////////////////
const gFooChild = new GFoo<ChildFoo>();

// 하위 클래스의 인스턴스를 설정
gFooChild.tValue = new ChildFoo();
console.log({
  value: gFooChild.getTValue(),
  instance: gFooChild.getTValue() instanceof ChildFoo,
});

// Logs:
// { instance: true, value: ChildFoo }

//--------------------------------------------------
// 베이스 클래스의 인스턴스를 설정하면 오류 발생
// (이번 예제에서는 베이스 클래스와 하위 클래스 간 필드에 차이가 있기 때문)
// (베이스 클래스와 하위 클래스의 필드에 차이가 없으면 오류가 발생하지 않음)
//
// Property 'value2' is missing in type 'BaseFoo' but required in type 'ChildFoo'.(2741)
gFooChild.tValue = new BaseFoo();

////////////////////////////////////////////////////
// 타입 인수에 베이스 클래스를 지정해도 인스턴스 생성 가능
////////////////////////////////////////////////////
// 베이스 클래스의 인스턴스를 설정
const gFooBase = new GFoo<BaseFoo>();
gFooBase.tValue = new BaseFoo();
console.log({
  value: gFooBase.getTValue(),
  instance: gFooBase.getTValue() instanceof BaseFoo,
});

// Logs:
// { instance: true, value: BaseFoo }

//--------------------------------------------------
// 하위 클래스의 인스턴스를 설정해도 문제 없음
gFooBase.tValue = new ChildFoo();
console.log({
  value: gFooBase.getTValue(),
  instance: gFooBase.getTValue() instanceof ChildFoo,
});

// Logs:
// { instance: true, value: ChildFoo }

// 단, 베이스 클래스에 없는 필드에는 접근할 수 없음
// Property 'value2' does not exist on type 'BaseFoo'. Did you mean 'value'?(2551)
gFooBase.getTValue().value2 = '#1';

////////////////////////////////////////////////////
// BaseFoo를 상속하지 않은 타입은 지정할 수 없음
////////////////////////////////////////////////////

// Type 'string' does not satisfy the constraint 'BaseFoo'.(2344)
const gFooNotExtends = new GFoo<string>();

제네릭 메서드

다음과 같은 특징을 가지고 있습니다.

  • 인수, 반환값, 로컬 변수의 타입을 메서드를 호출할 때 결정할 수 있다.
  • 메서드 이름 바로 뒤에 <...> 형식으로 타입 인수를 선언한다.

제네릭 메서드 타입

// 타입 인수가 하나인 경우
function gFuncFoo<T>(arg1: T, arg2: T): T {}

// 타입 인수가 두 개인 경우
function gFuncFoo<T, U>(arg1: T, arg2: U): U {}

 

 

예제 코드

/**
 * 임의의 타입을 인수로 받아 그 인수를 객체로 반환하는 함수
 */
function gFuncFoo<T>(arg1: T, arg2: T): { arg1: T, arg2: T } {
  return {
    arg1,
    arg2
  };
}
console.log(gFuncFoo("foo", "bar"));
// Logs:
// { arg1: "foo", arg2: "bar" }

/**
 * 임의의 타입을 여러 개 인수로 받아 그 인수를 튜플로 반환하는 함수
 */
function gFuncFooMulti<T, U>(arg1: T, arg2: U): [T, U] {
  return [
    arg1,
    arg2
  ];
}
console.log(gFuncFooMulti("foo", 100));
// Logs:
// ["foo", 100]

class Foo {
  /**
   * 임의의 타입 배열을 인수로 받아 그 배열을 결합해 반환하는 메서드
   */
  static gFuncConcat<T>(data: T[], data2: T[]): T[] {
    return data.concat(data2);
  }
}
console.log(Foo.gFuncConcat(["foo"], ["bar"]));
// Logs:
// ["foo", "bar"]

 

이렇게 TypeScript에서 제네릭을 사용하여 다양한 타입의 코드를 쉽게 작성하고, 타입 안전성을 유지하며 재사용 가능한 코드를 작성할 수 있습니다.