-
TypeScript 제네릭 패턴과 활용법 - 클래스, 함수, 메서드 완벽 분석Javascript 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 }
타입 인수 기본값 설정
타입 인수에는 기본값을 설정할 수도 있습니다.
기본값을 지정하여 클래스를 정의한 경우:
- 타입을 지정하지 않고 인스턴스를 생성하기
- 타입을 지정하여 인스턴스를 생성하기
두 가지 방법으로 인스턴스를 생성할 수 있습니다.
전자의 경우 기본값 타입을 다루는 인스턴스가 생성되고, 후자의 경우 지정된 타입을 다루는 인스턴스가 생성됩니다.
물론 기본값 타입을 지정하여 인스턴스를 생성할 수도 있습니다.
/** * 타입 인수의 기본값을 지정하여 임의의 타입을 다루는 클래스 */ 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에서 제네릭을 사용하여 다양한 타입의 코드를 쉽게 작성하고, 타입 안전성을 유지하며 재사용 가능한 코드를 작성할 수 있습니다.
'Javascript' 카테고리의 다른 글
TypeScript 고급 타입 마스터하기 - 객체 키 추출부터 infer 활용까지 (1) 2024.05.15 TypeScript의 효율적인 타입 관리 - any 없이 타입 정의, 타입 파생 및 제네릭 마스터하기 (1) 2024.05.15 TypeScript 4.5 이상 버전에서 추가된 TSConfig 옵션 살펴보기 (0) 2024.05.12 TypeScript를 다른 도구로 다루기 위한 컴파일러 옵션 (0) 2024.05.11 JSON-LD로 구조화된 데이터를 Next.js 사이트에 추가하여 SEO 개선하기 (0) 2024.05.03