TypeScript 배우기 - 7. 제네릭
본 글은 TypeScript 핸드북 Type Manipulation / Generics의 내용을 정리한 글입니다.
재사용 가능한 코드를 만들기 위해 여러 언어에서 제네릭을 사용합니다. 제네릭을 사용하면 단일 유형
이 아닌 다양한 유형
에서 작동할 수 있는 코드를 만들 수 있습니다. 이를 통해 코드를 좀 더 효율적으로 작성할 수 있게 됩니다.
기초
먼저 제네릭을 이해기 위해 TypeScript에서 identity()
라는 인자 값을 그대로 반환하는 함수를 살펴볼 것입니다.
function identity(arg: number): number {
return arg;
}
number
유형의 인자를 받아 그대로 반환합니다. 이 함수는 쉽고 어떻게 동작할지도 바로 알 수 있습니다. 하지만 이 함수는 number
유형만 처리가 가능합니다. 다른 유형도 처리 가능하도록 any
를 써볼 수 도 있습니다.
function identity(arg: any): any {
return arg;
}
이제 JavaScript의 기본 특성(any
)으로 인해 모든 유형의 인자를 받아 반환하는 함수가 되었습니다. 하지만 TypeScript는 유형 추적이 장점이니 만큼 any
유형을 지양해야 합니다. 이를 제네릭으로 개선할 수 있습니다.
제네릭은
일반화
의 개념으로 이해하면 됩니다.
function identity<Type>(arg: Type): Type {
return arg;
}
이제 identity()
함수가 받는 유형과 반환하는 유형이 실제 유형이 아니라 Type
이라는 제네릭 유형
를 사용하게 되었습니다. 이 함수를 실제 사용하기 전에는 단지 Type
이 될 수 있는 모든 유형이 후보가 됩니다.
이 후보는 아래의 코드에서 Type
이 string
으로 명시되면서 Type
--> string
이 됩니다.
let output = identity<string>("myString");
위의 코드에 의해 identity()
함수는 다음의 코드처럼 동작하게 됩니다.
function identity(arg: string): string {
return arg;
}
다른 정적 언어와 다르게 TypeScript는 JavaScript 코드를 생성하고 유형 표현은 사라지므로 실제로는
identity()
함수에 아무런 변화가 없습니다. 이와는 다르게 Java나 C#과 같은 정적 언어는 컴파일 수행중 함수가사용
되는 지점에서Type
-->string
으로 변환된identity()
함수를 생성합니다.
제네릭 유형
를 유추할 수 있는 경우 제네릭 유형
표현을 생략할 수 있습니다. identity()
함수의 경우 함수 인자 유형에 의해 반환 유형이 유추 되므로 생략할 수 있습니다.
let output = identity("myString");
제네릭 유형 변수 동작
제네릭 유형(여기서는 Type
)의 자리에 결국에 실제 유형이 사용될 터이지만 제네릭을 사용한 코드에서는 알 수 없으므로 마치 any
유형 처럼 동작합니다.
function loggingIdentity<Type>(arg: Type): Type {
console.log(arg.length);
~~~~~~~~~~
> Property 'length' does not exist on type 'Type'.
return arg;
}
제네릭 유형 Type
은 아직 유형이 특정되지 않았으므로 length
라는 속성이 있는지 알 수 없습니다. 그러므로 이 오류는 정상적이고 안전한 코드를 만들 수 있도록 하는 유용한 오류입니다. 우리는 TypeScript에게 좀 힌트를 알려줘야 합니다. 다음의 코드를 보시죠.
function loggingIdentity<Type>(arg: Type[]): Type[] {
console.log(arg.length);
return arg;
}
이제 arg
는 제네릭 유형 Type
인 배열을 의미하게 되었습니다. 배열이므로 length
속성에 접근할 수 있습니다. 이제 오류가 없어졌고 정상적인 코드라고 TypeScript는 인정합니다.
loggingIdentity()
함수는 이제 어떠한 유형이든 상관없이 배열 형태일 경우 정확히 그 배열 길이를 잘 출력하는 함수가 되었습니다.
Type[]
은 Array<Type>
과 같으므로 다음처럼 코드를 표현할 수 도 있습니다.
function loggingIdentity<Type>(arg: Array<Type>): Array<Type> {
console.log(arg.length); // Array has a .length, so no more error
return arg;
}
제네릭 유형
제네릭 함수의 유형은 함수 선언과 유사하게 유형 매개변수가 먼저 나열되는 비제네릭 함수의 유형과 유사합니다.
function identity<Type>(arg: Type): Type {
return arg;
}
let myIdentity: <Type>(arg: Type) => Type = identity;
<Type>(arg: Type) => Type
형태를 identity()
가 가지므로 정상적인 코드입니다. 사용은 다음처럼 할 수 있습니다.
let value = myIdentity<number>(5);
유형 변수의 수와 사용되는 방식이 일치하면 제네릭 유형 매개변수 (여기서는 Type
) 이름을 동일하게 맞출 필요가 없습니다. 즉, 다음의 코드도 정상 코드입니다.
function identity<Type>(arg: Type): Type {
return arg;
}
let myIdentity: <Input>(arg: Input) => Input = identity;
TypeScript에서는 객체 리터럴 유형의 호출 서명으로도 작성할 수 있게 허용합니다.
function identity<Type>(arg: Type): Type {
return arg;
}
let myIdentity: { <Type>(arg: Type): Type } = identity;
그러므로 인터페이스로도 이를 표현할 수 있습니다.
interface GenericIdentityFn {
<Type>(arg: Type): Type;
}
function identity<Type>(arg: Type): Type {
return arg;
}
let myIdentity: GenericIdentityFn = identity;
제네릭 유형 대신 실제 유형을 적용하고 싶을 수 도 있습니다.
interface GenericIdentityFn<Type> {
(arg: Type): Type;
}
function identity<Type>(arg: Type): Type {
return arg;
}
let myIdentity: GenericIdentityFn<number> = identity;
myIdentity
은 인터페이스에 의해 Type
제네릭 유형이 number
유형이 되었고 identity()
함수의 Type
제네릭 유형에 number
가 들어갈 수 있으므로 이 코드 역시 올바른 코드입니다.
제네릭 클래스
제네릭 함수와 마찬가지로 클래스에도 제네릭을 사용할 수 있습니다.
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;
};
NumType
제네릭 유형에는 number
뿐만 아니라 string
도 가능하므로 string
으로 클래스를 사용할 수도 있습니다.
let stringNumeric = new GenericNumber<string>();
stringNumeric.zeroValue = "";
stringNumeric.add = function (x, y) {
return x + y;
};
console.log(stringNumeric.add(stringNumeric.zeroValue, "test"));
제네릭 제약 조건
앞의 코드에서 length
속성을 사용하고자 했지만 제네릭 유형을 특정하지 않는 이상 불가능 하다는 것을 알 수 있었습니다.
function loggingIdentity<Type>(arg: Type): Type {
console.log(arg.length);
~~~~~~~~~~
> Property 'length' does not exist on type 'Type'.
return arg;
}
하지만 extends
키워드를 사용하면 이제 가능해 집니다.
interface Lengthwise {
length: number;
}
function loggingIdentity<Type extends Lengthwise>(arg: Type): Type {
console.log(arg.length); // Now we know it has a .length property, so no more error
return arg;
}
이를 제네릭 제약 조건이라고 하며 Type
제네릭 유형은 이제 반드시 length
속성이 있는 유형만 허용하게 됩니다.
loggingIdentity(3);
~
> Argument of type 'number' is not assignable to parameter of type 'Lengthwise'.
숫자 3은 number
유형이고 length
을 가지고 있지 않으므로 오류가 발생합니다. 이 오류는 loggingIdentity()
함수가 정상적으로 수행하기 위해 필요한 오류가 됩니다.
하지만 length
속성을 제공하는 모든 유형은 loggingIdentity()
함수를 사용할 수 있습니다.
loggingIdentity({ length: 10, value: 3 });
제네릭 제약 조건에서 유형 매개변수 사용
keyof
등의 키워드를 제약 조건에 사용하면 유형 검사를 좀 더 강화할 수 있습니다.
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"'.
getProperty()
함수는 인자의 속성을 반환하는 함수입니다. obj[key]
를 통해 어떤 key
의 속성도 반환할 수 있지만 제네릭 매개변수를 통해 extends keyof Type
으로 Key
제네릭 유형을 제한 헀고, 그것으로 key
로 받아서 x
의 속성에 없는 "m"의 경우 오류로 처리합니다.
훌륭하지 않나요?
제네릭에서 클래스 유형 사용
다음의 경우와 같이 팩토리를 만들 때 생성자 함수로 클래스 유형을 참조해야 할 수 있습니다.
function create<Type>(c: { new (): Type }): Type {
return new c();
}
고급 예제는 다음과 같습니다.
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;
이러한 패턴은 mixins 패턴을 강화하는데 사용됩니다.
정리
오늘은 TypeScript의 제네릭을 학습했습니다. 제네릭은 많은 언어에서 사용하는 방식이므로 제네릭 사용법을 익히면 다른 언어에서도 제네릭을 잘 사용할 수 있게 됩니다. 또한 제네릭을 효과적으로 사용하면 다양한 유형을 처리하기 위한 중복 코드를 없앨 수 있습니다.