💡 제네릭이란 ?
• 제네릭이 필요한 상황 (any, unknown)
제네릭이란 함수나 인터페이스, 타입 별칭, 클래스 등을 다양한 타입과 함께 동작하도록 만들어주는 타입스크립트의 기능입니다.
function func(value: any) {
return value;
}
let num = func(10); // any 타입
let str = func("string"); // any 타입
다음과 같이 다양한 타입의 매개변수를 받고 해당 매개변수를 그대로 반환하는 함수가 하나 있다고 가정하겠습니다. 일단 다양한 타입의 매개변수를 제공받아야 하기 때문에 매개변수 value의 타입을 any 타입으로 임시로 지정하였습니다.
이 함수는 인수로 전달한 값을 그냥 그대로 반환하는 단순한 함수입니다. 따라서 변수 num에는 10이 저장되고 변수 str에는 "string"이 저장됩니다. 그런데 변수 위에 커서를 올려두어서 타입을 확인해보면 num과 str의 타입은 any 타입이 됩니다. 왜냐하면 func 함수의 반환값의 타입이 return 문을 기준으로 추론되었기 때문입니다.
let num = func(10); // num: any 타입으로 저장된 경우
num.toUpperCase(); // ❌ 오류문장인데 타입스크립트가 오류를 감지 못하는 상황
하지만 함수 호출 결과를 저장하는 num 같은 변수가 any 타입으로 추론되면 다음과 같은 문제점이 발생합니다. num에는 Number 타입의 값 10이 저장되어 있을 것입니다. 하지만 이 변수는 any 타입으로 추론되어 버렸기 떄문에 toUpperCase 등의 string 타입의 메서드를 사용해도 타입스크립트가 오류를 감지하지 못합니다. 결국 이러한 상태로 실행하면 런타임 오류를 발생시키게 됩니다.
function func(value: unknown) {
return value;
}
let num = func(10); // unknown 타입
let str = func("string"); // unknown 타입
num.toUpperCase(); // ❌
num.toFixed(); // ❌
이번에는 매개변수의 타입을 unknown 타입으로 정의해보았습니다. 그러면 num에 toUpperCase 같은 메서드 호출은 방지할 수 있습니다. 하지만 toFixed 같은 number 타입의 메서드 호출도 함꼐 오류로 나타납니다.
if (typeof num === "number") {
num.toFixed();
}
num에 10이라는 값이 저장될 것이 분명하지만 이 값을 사용하기 위해서는 비효율적인 타입 좁히기를 사용해야 합니다.
인수로 Number 타입의 값을 전달하면 반환 타입이 Number가 되고, 인수로 String 타입의 값을 전달하면 반환값의 타입도 String 타입이 되었으면 하는 것인데 이럴 때 제네릭을 사용하면 됩니다.
• 제네릭(Generic) 함수
제네릭 함수는 타입 변수와 함께 여러 타입의 값을 인수로 받아서 범용적으로 사용할 수 있는 함수를 의미합니다.
function func<T>(value: T): T {
return value;
}
let num = func(10); // number 타입
제네릭 함수를 선언하였습니다. 함수 이름 뒤에 꺽쇠( < > )를 열고 타입을 담는 변수인 타입 변수 T를 선언합니다. 그리고 매개변수와 반환값의 타입을 이 타입변수 T로 설정합니다. T에 어떤 타입이 할당될 지는 함수가 호출될 때 결정됩니다.
func(10)처럼 Number 타입의 값을 인수로 전달하면 매개변수 value에 Number 타입의 값이 저장되면서 T가 Number 타입으로 추론됩니다. 자동으로 func 함수의 반환값 타입 또한 Number 타입이 됩니다.
function func<T>(value: T): T {
return value;
}
let arr = func<[number, number, number]>([1, 2, 3]);
제네릭 함수를 호출할 때 다음과 같이 타입 변수에 할당할 타입을 직접 명시할 수 있습니다.
T에 [Number, Number, Number] 이라는 튜플 타입이 할당되고, 매개변수 value 와 반환값 타입이 모두 튜플 타입이 됩니다. 만약 타입 변수에 할당할 타입ㅇ르 튜플 타입으로 설정하지 않았다면 T가 number[ ] 타입으로 추론되었을 것입니다. 왜냐하면 타입스크립트는 타입을 추론할 때 항상 일반적이고 좀 더 범용적인 타입으로 추론하기 때문입니다.
이렇게 타입 변수에 할당하고 싶은 특정 타입이 존재한다면 함수 호출과 함께 꺽쇠를 열고 직접 명시해 주는 것이 좋습니다. 그것이 아니라면 굳이 타입 변수를 설정하지 않아도 됩니다.
💡 제네릭의 타입 변수 활용 방법 4가지
• 첫번째 사례
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 타입이 됩니다.
• 세번째 사례
2번 사례에서 만약 반환값의 타입을 배열의 첫번째 요소의 타입이 되도록 할 때에는 튜플 타입과 나머지 파라미터를 사용하면 됩니다.
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;
}
let var1 = getLength([1, 2, 3]); // 3
let var2 = getLength("12345"); // 5
let var3 = getLength({ length: 10 }); // 10
let var4 = getLength(10); // ❌
타입 변수를 제한할 때에는 확장(extends)를 이용합니다.
위 코드에서처럼 T extends { length: number } 라고 정의하면 T는 이제 { length: number } 객체 타입의 서브타입이 됩니다. 다시 말해 T는 무조건 Number 타입의 프로퍼티인 length를 가지고 있는 타입이 되어야 한다는 의미입니다.
✱ var1 → 인수로 length 프로퍼티가 존재하는 Number[ ] 타입의 값을 전달하였습니다.
✱ var2 → 인수로 length 프로퍼티가 존재하는 String 타입의 값을 전달하였습니다.
✱ var3 → 인수로 length 프로퍼티가 존재하는 객체 타입의 값을 전달하였습니다.
✱ var4 → 인수로 length 프로퍼티가 존재하지 않는 Number 타입의 값을 전달하였습니다.
💡 메서드 타입 정의하기 (map, forEach)
• Map 메서드 타입 정의하기
const arr = [1, 2, 3];
const newArr = arr.map((it) => it * 2);
console.log(newArr); // [2, 4, 6]
자바스크립트의 배열 메서드 Map은 다음과 같이 원본 배열의 각 요소에 콜백함수를 수행하고 반환된 값들을 모아 새로운 배열로 만들어 반환합니다.
map 메서드를 직접 함수로 만들고 타입도 정의할 것이며 우선 일반적인 함수로 만들어보겠습니다.
// 일반적인 함수
function map(arr: unknown[], callback: (item: unknown) => unknown): unknown[] {}
메서드를 적용할 배열을 매개변수 arr로 받고, 콜백 함수를 매개변수 callback으로 받습니다. map 메서드는 모든 타입의 배열에 적용할 수 있기 때문에 arr의 타입은 unknown[ ]으로 정의합니다. callback의 타입은 배열 요소 하나를 매개변수로 받아 특정값을 반환하는 함수로 정의합니다. 그리고 map 메서드의 반환값의 타입은 배열 타입으로 정의합니다.
// 제네릭 함수
const arr = [1, 2, 3];
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;
}
map(arr, (it) => it * 2); // 결과: [2, 4, 6]
map(arr, (it) => it.toString()); // ❌
다음으로는 이 함수에 타입 변수를 선언하여 제네릭 함수로 만듭니다. 모든 unknown 타입을 타입 변수 T로 대체하였습니다. 이때 매개변수 arr에 number[ ] 타입의 배열을 제공하였더니 타입변수 T가 number로 추론되고 그 결과 map 함수의 반환값 타입도 number[ ]가 되었습니다. 하지만 마지막 줄 코드에서 보면 콜백함수가 모든 배열 요소를 String 타입으로 변환하도록 하였더니 오류가 발생하였습니다. 첫번째 인수로 arr을 전달하였을 때 타입 변수 T에는 number 타입이 할당되었기 때문에 콜백 함수의 반환값 타입도 number 타입이 되어야 하기 때문입니다.
그런데, map 메서드가 반드시 이렇게 원본 배열 타입과 같은 타입의 배열로만 변환해야한다는 이유는 없습니다!
원본배열의 타입과 다른 타입의 배열로도 반환하고자 한다면 타입변수를 하나 더 추가하여 수정하면 됩니다.
function map<T, U>(arr: T[], callback: (item: T) => U) {
(...)
}
map(arr, (it) => it.toString());
원본 배열의 타입(T[ ])과 새롭게 반환하는 배열의 타입(U[ ])을 다르게 설정해 주었습니다. 이와 같이 설정해주면 마지막 줄 코드에서 오류가 더이상 발생하지 않습니다.
• forEach 메서드 타입 정의하기
forEach 메서드는 배열의 모든 요소에 콜백함수를 한번씩 수행해주는 메서드입니다. forEach 메서드는 Map 메서드보다 훨씬 정의하기 편하고 쉽습니다.
const arr2 = [1, 2, 3];
arr2.forEach((it) => console.log(it));
function forEach<T>(arr: T[], callback: (item: T) => void) {
for (let i = 0; i < arr.length; i++) {
callback(arr[i]);
}
}
Map과 동일하게 arr, callback이라는 2개의 매개변수를 받습니다. 첫번째 매개변수 arr에는 순회 대상 배열을 제공받고, 두번째 매개변수 callback에는 모든 배열 요소에 수행할 콜백함수를 제공받습니다. 이때 아까 Map 메서드의 타입 정의와는 달리 forEach 메서드는 반환값이 없는 메서드이므로 콜백 함수의 반환값의 타입을 void로 정의합니다.
출처) 한 입 크기로 잘라먹는 타입스크립트(TypeScript)_이정환
'📍 프로그래밍 언어 > TypeScript' 카테고리의 다른 글
[ TypeScript ] 타입 조작하기 1 - 인덱스드 액세스 타입, keyof & typeof 연산자 (0) | 2024.12.23 |
---|---|
[ TypeScript ] 제네릭으로 확장하기: 인터페이스, 클래스, 프로미스 (0) | 2024.12.22 |
[ TypeScript ] 클래스 이해하기: JavaScript 차이점, 인터페이스, 그리고 접근 제어자 (0) | 2024.12.22 |
[ TypeScript ] 인터페이스의 활용: 기본 개념부터 확장과 선언 합치기 (0) | 2024.12.21 |
[ TypeScript ] 함수 오버로딩과 커스텀 타입가드로 복잡한 타입 관리하기 (0) | 2024.12.19 |