제네릭이란 함수나 인터페이스, 타입 별칭, 클래스 등을 다양한 타입과 함께 동작하도록 만들어 주는 타입스크립트의 놀라운 기능 중 하나이다.
다음과 같이 다양한 타입의 매개변수를 받고 해당 매개변수를 그대로 반환하는 함수가 하나 필요하다고 가정하겠습니다.
function func(value: any) {
return value;
}
let num = func(10);
// any 타입
let str = func("string");
// any 타입
다양한 타입의 매개변수를 제공받아야 하기 때문에 매개변수 value의 타입을 일단 any 타입으로 해 두었습니다. 물론 unknown으로 정의해도 괜찮습니다.
이 함수는 인수로 전달한 값을 그냥 그대로 반환하는 단순한 함수입니다. 따라서 변수 num에는 10이 저장되고 변수 str에는 “string”이 저장됩니다. 그런데 현재 num과 str의 타입은 any 타입이 됩니다. func 함수의 반환값 타입이 return 문을 기준으로 추론되었기 때문입니다.
이렇게 함수 호출 결과를 저장하는 num, str 등의 변수가 any 타입으로 추론되면 다음과 같은 문제점이 발생합니다.
function func(value: any) {
return value;
}
let num = func(10);
let str = func("string");
num.toUpperCase()
num에는 분명 Number 타입의 값 10이 저장되어 있을 것이 분명합니다. 그러나 any 타입으로 추론되어 버렸기 때문에 toUpperCase 등의 String 타입의 메서드를 사용해도 타입스크립트가 오류를 감지하지 못합니다. 이 코드는 결국 실제로 실행하면 런타임 오류를 발생시키는 아주 위험한 상태가 됩니다.
자 그러면 이번에는 매개변수의 타입을 any 타입 말고 unknown 타입으로 정의해보겠습니다.
function func(value: unknown) {
return value;
}
let num = func(10);
// unknown 타입
let str = func("string");
// unknown 타입
num.toUpperCase(); // ❌
num.toFixed(); // ❌
그러면 toUpperCase 같은 메서드 호출은 방지할 수 있습니다. 그러나 이번에는 또 다른 문제가 발생합니다. toFixed 같은 Number 타입의 메서드 호출도 함께 오류로 판단하게 됩니다.
따라서 num에 10이 저장될 것이 분명한데도 이 값을 사용하려면 다음과 같이 비효율적으로 타입 좁히기를 이용해야 합니다.
function func(value: unknown) {
return value;
}
let num = func(10);
// unknown 타입
let str = func("string");
// unknown 타입
if (typeof num === "number") {
num.toFixed();
}
사실 우리가 원하는 건 꽤나 간단합니다. 그냥 인수로 Number 타입의 값을 전달하면 반환 타입이 Number가 되고, 인수로 String 타입의 값을 전달하면 반환값의 타입도 String 타입이 되었으면 좋겠습니다. 그런데 지금까지 우리가 배운 타입스크립트 문법으로는 이 문제를 해결할 수 없습니다.
이럴때 바로 제네릭을 이용해야 합니다. func 함수를 제네릭 함수로 만들면 이 문제를 간단히 해결할 수 있습니다.
제네릭이란 일반적인 또는 포괄적인 이라는 뜻을 갖고 있다.
따라서 제네릭 함수는 일반적인 함수
또는 포괄적인 함수
정도로 해석할 수 있습니다. 이게 무슨 말 일까요?
제네릭과 그 의미가 거의 비슷한 단어가 하나 있습니다. 제네럴
(General) 입니다.
제네럴과 제네릭은 두루두루 다 적용될 수 있는
, 또는 포괄적인 이런 뜻으로 의역해볼 수 있습니다.
따라서 제네릭 함수는
두루두루 모든 타입의 값을 다 적용할 수 있는 그런 범용적인 함수
이다 정도로 이해할 수 있습니다.
다음과 같이 제네릭 함수를 선언할 수 있습니다.
function func<T>(value: T): T {
return value;
}
let num = func(10);
// number 타입
함수 이름 뒤에 꺽쇠를 열고 타입을 담는 변수인 타입 변수 T를 선언합니다. 그리고 매개변수와 반환값의 타입을 이 타입변수 T로 설정합니다.
T에 어떤 타입이 할당될 지는 함수가 호출될 때 결정됩니다.
func(10) 처럼 Number 타입의 값을 인수로 전달하면 매개변수 value에 Number 타입의 값이 저장되면서 T가 Number 타입으로 추론됩니다. 이때 T가 Number 타입으로 추론됩니다. 그럼 이때의 func 함수의 반환값 타입또한 Number 타입이 됩니다.
제네릭 함수를 호출할 때 다음과 같이 타입 변수에 할당할 타입을 직접 명시하는 것도 가능합니다.
function func<T>(value: T): T {
return value;
}
let arr = func<[number, number, number]>([1, 2, 3]);
위 코드의 흐름은 다음과 같습니다.
1. T에 [Number, Number, Number] 튜플 타입이 할당됨
2. 매개변수 value와 반환값 타입이 모두 튜플 타입이 됨
만약 위 코드에서 타입 변수에 할당할 타입을 튜플 타입으로 설정하지 않았다면 T가 number[] 타입으로 추론 되었을 것 입니다. 타입스크립트는 타입을 추론할 때 항상 일반적이고 좀 더 범용적인 타입으로 추론하기 때문입니다.
이렇듯 타입 변수에 할당하고 싶은 특정 타입이 존재한다면 함수 호출과 함께 꺽쇠를 열고 직접 명시해주는게 좋습니다.
그렇지 않은 대다수의 상황에서는 알아서 잘 추론되기 때문에 굳이 타입 변수를 설정하지 않아도 됩니다.
제네릭의 타입 변수를 더 잘 활용하는 방법에 대해 살펴봅니다.
만약 2개의 타입 변수가 필요한 상황이라면 다음과 같이 T, U 처럼 2개의 타입 변수를 사용해도 됩니다.
function swap<T, U>(a: T, b: U) {
return [b, a];
}
const [a, b] = swap("1", 2);
위 코드에서 T는 String 타입으로 U는 Number 타입으로 추론됩니다.
다양한 배열 타입을 인수로 받는 제네릭 함수를 만들어야 한다면 다음과 같이 할 수 있습니다.
function returnFirstValue<T>(data: T[]) {
return data[0];
}
let num = returnFirstValue([0, 1, 2]);
// number
let str = returnFirstValue([1, "hello", "mynameis"]);
// number | string
함수 매개변수 data의 타입을 T[]로 설정했기 때문에 배열이 아닌 값은 인수로 전달할 수 없게 됩니다. 배열을 인수로 전달하면 T는 배열의 요소 타입으로 할당됩니다.
첫번째 호출에서는 인수로 Number[] 타입의 값을 전달했으므로 이때의 T는 Number 타입으로 추론됩니다. 이때의 함수 반환값 타입은 Number 타입이 됩니다.
두번째 호출에서는 인수로 (String | Number)[] 타입의 값을 전달했으므로 이때의 T는 String | Number 타입으로 추론됩니다. 이때의 함수 반환값 타입은 String | Number 타입이 됩니다.
위 사례에서 만약 반환값의 타입을 배열의 첫번째 요소의 타입이 되도록 하려면 다음과 같이 튜플 타입과 나머지 파라미터를 이용하면 됩니다.
function returnFirstValue<T>(data: [T, ...unknown[]]) {
return data[0];
}
let str = returnFirstValue([1, "hello", "mynameis"]);
// number
함수 매개변수의 타입을 정의할 때 튜플 타입을 이용해 첫번째 요소의 타입은 T 그리고 나머지 요소의 타입은 …unknown[] 으로 길이도 타입도 상관 없도록 정의합니다.
함수를 호출하고 [1, “hello”, “mynameis”] 같은 배열 타입의 값을 인수로 전달하면 T는 첫번째 요소의 타입인 Number 타입이 됩니다. 따라서 함수 반환값 타입또한 Number 타입이 됩니다.
마지막으로 살펴볼 사례는 타입 변수를 제한하는 사례입니다. 타입 변수를 제한한다는 것은 함수를 호출하고 인수로 전달할 수 있는 값의 범위에 제한을 두는 것을 의미합니다.
다음은 타입 변수를 적어도 length 프로퍼티를 갖는 객체 타입으로 제한한 예시 입니다.
function getLength<T extends { length: number }>(data: T) {
return data.length;
}
getLength("123"); // ✅
getLength([1, 2, 3]); // ✅
getLength({ length: 1 }); // ✅
getLength(undefined); // ❌
getLength(null); // ❌
타입 변수를 제한할 때에는 확장(extends)을 이용합니다.
위와 같이 T extends { length : number } 라고 정의하면 T는 이제 { length : number } 객체 타입의 서브 타입이 됩니다. 바꿔말하면 이제 T는 무조건 Number 타입의 프로퍼티 length 를 가지고 있는 타입이 되어야 한다는 것 입니다. 따라서 이렇게 extends를 이용해 타입 변수를 제한하면 아래와 같은 결과가 나타납니다.
이전에 배운 제네릭을 이용해 자바스크립트의 배열 메서드 Map, ForEach를 직접 구현하고 타입을 정의해보겠습니다.
자바스크립트의 배열 메서드 Map은 다음과 같이 원본 배열의 각 요소에 콜백함수를 수행하고 반환된 값들을 모아 새로운 배열로 만들어 반환합니다.
const arr = [1, 2, 3];
const newArr = arr.map((it) => it * 2);
// [2, 4, 6]
map 메서드를 직접 함수로 만들고 타입도 정의하겠습니다.
먼저 제네릭 함수가 아닌 일반적인 함수로 만듭니다.
function map(arr: unknown[], callback: (item: unknown) => unknown): unknown[] {}
메서드를 적용할 배열을 매개변수 arr로 받고, 콜백 함수를 매개변수 callback으로 받습니다.
map 메서드는 모든 타입의 배열에 적용할 수 있기 때문에 arr의 타입은 unknown[]으로 정의합니다. callback의 타입은 배열 요소 하나를 매개변수로 받아 특정 값을 반환하는 함수로 정의합니다. 함수 타입 표현식을 이용했습니다. 마지막으로 map 메서드의 반환값의 타입은 배열 타입으로 정의합니다.
다음으로는 이 함수에 타입 변수를 선언하여 제네릭 함수로 만듭니다.
function map<T>(arr: T[], callback: (item: T) => T): T[] {}
모든 unknown 타입을 타입 변수 T로 대체합니다. 다음으로는 함수 내부를 구현합니다.
function map<T>(arr: T[], callback: (item: T) => T): T[] {
let result = [];
for (let i = 0; i < arr.length; i++) {
result.push(callback(arr[i]));
}
return result;
}
1차적으로 map 함수를 만들고 타입 정의도 마쳤습니다. 이제 함수를 호출합니다.
const arr = [1, 2, 3];
function map<T>(arr: T[], callback: (item: T) => T): T[] {
(...)
}
map(arr, (it) => it * 2);
// number[] 타입의 배열을 반환
// 결과 : [2, 4, 6]
잘 동작하는 것 같습니다. 매개변수 arr에 number[] 타입의 배열을 제공하니 타입변수 T가 number로 추론되고 그 결과 map 함수의 반환값 타입도 number[]가 되었습니다.
그런데 문제가 한가지 있습니다. 함수 호출을 다음과 같이 수정하면 오류가 발생합니다.
const arr = [1, 2, 3];
function map<T>(arr: T[], callback: (item: T) => T): T[] {
(...)
}
map(arr, (it) => it.toString()); // ❌
콜백함수가 모든 배열 요소를 String 타입으로 변환하도록 수정했습니다. 이러면 오류가 발생합니다. 첫번째 인수로 arr을 전달했을 때 타입 변수 T에는 number 타입이 할당되었기 때문에 콜백 함수의 반환값 타입도 number 타입이 되어야 하기 때문입니다.
그런데 map 메서드는 이렇게 원본 배열 타입과 다른 타입의 배열로도 변환할 수 있어야 합니다. 따라서 타입 변수를 하나 더 추가해 다음과 같이 수정합니다.
const arr = [1, 2, 3];
function map<T, U>(arr: T[], callback: (item: T) => U): U[] {
(...)
}
map(arr, (it) => it.toString());
// string[] 타입의 배열을 반환
// 결과 : ["1", "2", "3"]
원본 배열의 타입과 새롭게 반환하는 배열의 타입을 다르게 설정해 주었습니다. 그 결과 호출문의 오류가 사라지고 잘 작동합니다.
다음으로는 forEach 메서드도 직접 구현하고 타입도 함께 정의해보겠습니다.
forEach 메서드는 다음과 같이 배열의 모든 요소에 콜백함수를 한번씩 수행해주는 메서드 입니다.
const arr2 = [1, 2, 3];
arr2.forEach((it) => console.log(it));
// 출력 : 1, 2, 3
function forEach<T>(arr: T[], callback: (item: T) => void) {
for (let i = 0; i < arr.length; i++) {
callback(arr[i]);
}
}
Map과 동일하게 2개의 매개변수를 받습니다.
첫번째 매개변수 arr에는 순회 대상 배열을 제공받고 두번째 매개변수 callback에는 모든 배열 요소에 수행할 함수를 제공 받습니다. 이때 아까 Map 메서드의 타입 정의와는 달리 forEach 메서드는 반환값이 없는 메서드이므로 콜백 함수의 반환값 타입을 void로 정의합니다.
제네릭은 인터페이스에도 적용할 수 있습니다. 다음과 같이 인터페이스에 타입 변수를 선언해 사용하면 됩니다.
interface KeyPair<K, V> {
key: K;
value: V;
}
키페어를 저장하는 객체의 타입을 제네릭 인터페이스로 정의했습니다.
다음과 같이 변수의 타입으로 정의하여 사용할 수 있습니다.
let keyPair: KeyPair<string, number> = {
key: "key",
value: 0,
};
let keyPair2: KeyPair<boolean, string[]> = {
key: true,
value: ["1"],
};
변수 keyPair의 타입으로 KeyPair<string, number>를 정의했습니다. 그 결과 K에는 string, V에는 number 타입이 각각 할당되어 key 프로퍼티는 string 타입이고 value 프로퍼티는 number 타입인 객체 타입이 됩니다. 따라서 값으로 해당 타입의 객체를 저장합니다.
변수 keyPair2의 타입으로 KeyPair<boolean, string[]>를 정의했습니다. 그 결과 K에는 boolean, V에는 string[] 타입이 각각 할당되어 key 프로퍼티는 boolean 타입이고 value 프로퍼티는 string[] 타입인 객체 타입이 됩니다. 따라서 값으로 해당 타입의 객체를 저장합니다.
이때 주의해야 할 점이 한가지 있는데 제네릭 인터페이스는 제네릭 함수와는 달리 변수의 타입으로 정의할 때 반드시 꺽쇠와 함께 타입 변수에 할당할 타입을 명시해주어야 합니다.
그 이유는 제네릭 함수는 매개변수에 제공되는 값의 타입을 기준으로 타입 변수의 타입을 추론할 수 있지만 인터페이스는 마땅히 추론할 수 있는 값이 없기 때문입니다.
제네릭 인터페이스는 인덱스 시그니쳐와 함께 사용하면 다음과 같이 기존보다 훨씬 더 유연한 객체 타입을 정의할 수 있습니다.
interface Map<V> {
[key: string]: V;
}
let stringMap: Map<string> = {
key: "value",
};
let booleanMap: Map<boolean> = {
key: true,
};
한개의 타입 변수 V를 갖는 제네릭 인터페이스 Map을 정의했습니다. 이 인터페이스는 인덱스 시그니쳐로 key의 타입은 string, value의 타입은 V인 모든 객체 타입을 포함하는 타입입니다.
변수 stringMap의 타입을Map<string>
으로 정의했습니다. 따라서 V가 string 타입이 되어 이 변수의 타입은 key는 string이고 value는 string인 모든 프로퍼티를 포함하는 객체 타입으로 정의됩니다.
변수 booleanMap의 타입을 Map<boolean>
으로 정의했습니다. 따라서 V가 boolean 타입이 되어 이 변수의 타입은 key는 string이고 value는 boolean인 모든 프로퍼티를 포함하는 객체 타입으로 정의됩니다.
인터페이스와 마찬가지로 타입 별칭에도 역시 제네릭을 적용할 수 있습니다.
type Map2<V> = {
[key: string]: V;
};
let stringMap2: Map2<string> = {
key: "string",
};
제네릭 타입 별칭을 사용할 때에도 제네릭 인터페이스와 마찬가지로 타입으로 정의될 때 반드시 타입 변수에 설정할 타입을 명시해 주어야 합니다.
제네릭 인터페이스의 실용 사례를 하나 소개합니다.
개발자 또는 학생이 이용하는 어떤 프로그램이 있다고 가정합니다.
interface Student {
type: "student";
school: string;
}
interface Developer {
type: "developer";
skill: string;
}
interface User {
name: string;
profile: Student | Developer;
}
function goToSchool(user: User<Student>) {
if (user.profile.type !== "student") {
console.log("잘 못 오셨습니다");
return;
}
const school = user.profile.school;
console.log(`${school}로 등교 완료`);
}
const developerUser: User = {
name: "이정환",
profile: {
type: "developer",
skill: "typescript",
},
};
const studentUser: User = {
name: "홍길동",
profile: {
type: "student",
school: "가톨릭대학교",
},
};
학생을 의미하는 Student와 개발자를 의미하는 Developer 타입을 정의했습니다.
두 타입 모두 String Literal 타입의 type 프로퍼티를 갖고 있으며, 서로소 유니온 타입입니다.
그리고 그 아래에 학생일수도 개발자일 수도 있는 User 타입을 정의합니다. 특정 객체가 학생이라면 profile 프로퍼티에 Student 타입의 객체가 저장될 것이고, 그렇지 않다면 Developer 타입의 객체가 저장될 것 입니다.
그 아래에 학생 유저만 이용할 수 있는 함수 goToSchool을 선언했습니다. 이 함수에서는 일단 User 타입의 객체를 받아 타입을 좁혀 이 유저가 학생일 때에만 “등교 완료”를 콘솔에 출력합니다.
위 코드는 겉으로 보았을때에는 지금 당장은 별 문제가 없어보입니다. 그러나 학생만 할 수 있는 기능이 점점 많아진다고 가정하면 매번 기능을 만들기 위해 함수를 선언할 때 마다 조건문을 이용해 타입을 좁혀야 하기 때문에 결국 매우 불편해 질 것 입니다. 게다가 타입을 좁히는 코드는 중복 코드가 될 것 입니다.
이럴 때 바로 제네릭 인터페이스를 이용하면 좋습니다. 다음과 같이 User 인터페이스를 제네릭 인터페이스로 업그레이드 합니다.
interface Student {
type: "student";
school: string;
}
interface Developer {
type: "developer";
skill: string;
}
interface User<T> {
name: string;
profile: T;
}
function goToSchool(user: User<Student>) {
const school = user.profile.school;
console.log(`${school}로 등교 완료`);
}
const developerUser: User<Developer> = {
name: "이정환",
profile: {
type: "developer",
skill: "TypeScript",
},
};
const studentUser: User<Student> = {
name: "홍길동",
profile: {
type: "student",
school: "가톨릭대학교",
},
};
그럼 이제 goToSchool 함수의 매개변수 타입을 User<Student>
처럼 정의해 학생 유저만 이 함수의 인수로 전달하도록 제한할 수 있습니다. 결과적으로 함수 내부에서 타입을 좁힐 필요가 없어지므로 코드가 훨씬 간결해집니다.
제네릭의 마지막으로 제네릭 클래스에 대해 살펴보겠습니다.
예시와 함께 살펴보기 위해 먼저 제네릭이 아닌 간단한 Number 타입의 리스트를 생성하는 클래스를 하나 만듭니다.
class NumberList {
constructor(private list: number[]) {}
push(data: number) {
this.list.push(data);
}
pop() {
return this.list.pop();
}
print() {
console.log(this.list);
}
}
const numberList = new NumberList([1, 2, 3]);
list 필드를 private(접근 제어자)으로 설정해 클래스 내부에서만 접근할 수 있도록 만들고, 생성자에서 필드 선언과 함께 초기화 합니다. 새로운 요소를 추가하는 push, 제거하는 pop, 출력하는 print 메서드도 만들어 주었습니다.
그런데 만약 이때 StringList 클래스도 하나 필요하다면 어떻게 해야 할까요? 제네릭 없이는 다음과 같이 어쩔 수 없이 새로운 클래스를 하나 더 만들어줘야 합니다.
class NumberList {
constructor(private list: number[]) {}
(...)
}
class StringList {
constructor(private list: string[]) {}
push(data: string) {
this.list.push(data);
}
pop() {
return this.list.pop();
}
print() {
console.log(this.list);
}
}
const numberList = new NumberList([1, 2, 3]);
const numberList = new StringList(["1", "2", "3"]);
매우 비 효율적입니다. 모든 리스트에 메서드가 새롭게 추가된다거나 동작이 수정되는 경우라도 생각하면 벌써 끔찍합니다. 따라서 이럴 때 다음과 같이 제네릭 클래스를 사용해 여러 타입의 리스트를 생성할 수 있는 범용적을 클래스를 정의하면 됩니다.
class List<T> {
constructor(private list: T[]) {}
push(data: T) {
this.list.push(data);
}
pop() {
return this.list.pop();
}
print() {
console.log(this.list);
}
}
const numberList = new List([1, 2, 3]);
const stringList = new List(["1", "2"]);
클래스의 이름 뒤에 타입 변수를 선언하면 제네릭 클래스가 됩니다. 이 타입 변수는 이제 클래스 내부에서 자유롭게 사용할 수 있습니다. 또 클래스는 생성자를 통해 타입 변수의 타입을 추론할 수 있기 때문에 생성자에 인수로 전달하는 값이 있을 경우 타입 변수에 할당할 타입을 생략해도 됩니다.
만약 타입변수의 타입을 직접 설정하고 싶다면 다음과 같이 하면 됩니다.
class List<T> {
constructor(private list: T[]) {}
(...)
}
const numberList = new List<number>([1, 2, 3]);
const stringList = new List<string>(["1", "2"]);
Promise는 제네릭 클래스로 구현되어 있습니다. 따라서 새로운 Promise를 생성할 때 다음과 같이 타입 변수에 할당할 타입을 직접 설정해 주면 해당 타입이 바로 resolve 결과값의 타입이 됩니다.
const promise = new Promise<number>((resolve, reject) => {
setTimeout(() => {
// 결과값 : 20
resolve(20);
}, 3000);
});
promise.then((response) => {
// response는 number 타입
console.log(response);
});
promise.catch((error) => {
if (typeof error === "string") {
console.log(error);
}
});
아쉽게도 reject 함수에 인수로 전달하는 값 즉 실패의 결과값 타입은 정의할 수 없습니다. 그냥 unknown 타입으로 고정되어 있기 때문에 catch 메서드에서 사용하려면 타입 좁히기를 통해 안전하게 사용하는걸 권장합니다.
만약 어떤 함수가 Promise 객체를 반환한다면 함수의 반환값 타입을 위해 다음과 같이 할 수 있습니다.
function fetchPost() {
return new Promise<Post>((resolve, reject) => {
setTimeout(() => {
resolve({
id: 1,
title: "게시글 제목",
content: "게시글 본문",
});
}, 3000);
});
}
또는 더 직관적으로 다음과 같이 반환값 타입을 직접 명시해도 됩니다.
function fetchPost(): Promise<Post> {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve({
id: 1,
title: "게시글 제목",
content: "게시글 본문",
});
}, 3000);
});
}