📑 목차
지난 포스팅에서 제네릭의 기본 개념(T를 변수처럼 사용)을 익혔다. 이번에는 다양한 상황에서 제네릭을 어떻게 응용할 수 있는지 알아본다.
1. 제네릭 함수 응용
1-1. 다중 타입 변수 (T, U)
필요하다면 타입 변수를 2개 이상 선언해서 사용할 수 있다.
function swap<T, U>(a: T, b: U) {
return [b, a];
}
const [a, b] = swap("1", 2);
// a는 number, b는 string 타입으로 자동 추론됨
1-2. 배열 타입 다루기 (T[])
배열의 요소 타입을 제네릭으로 받으면, 다양한 타입의 배열을 처리하는 함수를 만들 수 있다.
function returnFirstValue<T>(data: T[]) {
return data[0];
}
let num = returnFirstValue([0, 1, 2]); // T는 number, 반환값 number
let str = returnFirstValue([1, "hello", "mynameis"]); // T는 number | string
💡 튜플로 첫 번째 요소 타입 확정하기 만약 배열의 첫 번째 요소 타입을 정확히 추론하고 싶다면 튜플과 나머지 파라미터를 활용한다.
function returnFirstValue<T>(data: [T, ...unknown[]]) {
return data[0];
}
let str = returnFirstValue([1, "hello", "mynameis"]);
// 반환값이 정확히 number로 추론됨 (유니온 타입 아님)
1-3. 타입 변수 제한하기 (extends)
타입 변수에 아무 타입이나 들어오는 것을 막고, 특정 조건을 만족하는 타입만 받도록 제한할 수 있다.
// length 프로퍼티가 있는 타입만 허용
function getLength<T extends { length: number }>(data: T) {
return data.length;
}
getLength("123"); // ✅ (string은 length 있음)
getLength([1, 2, 3]); // ✅ (array는 length 있음)
getLength({ length: 1 }); // ✅ (객체에 length 있음)
// getLength(undefined); // ❌ (length 없음)
2. map & forEach 메서드 직접 구현하기
자바스크립트의 내장 메서드인 map과 forEach도 제네릭을 이용해 타입을 정의할 수 있다.
map 메서드 타입 정의
map은 원본 배열의 타입(T)과 변환된 배열의 타입(U)이 다를 수 있으므로, 두 개의 타입 변수가 필요하다.
function map<T, U>(arr: T[], callback: (item: T) => U): U[] {
let result = [];
for (let i = 0; i < arr.length; i++) {
result.push(callback(arr[i]));
}
return result;
}
const arr = [1, 2, 3];
const result = map(arr, (it) => it.toString());
// result는 string[] 타입 ["1", "2", "3"]
forEach 메서드 타입 정의
forEach는 반환값이 없으므로(void), 비교적 간단하게 정의할 수 있다.
function forEach<T>(arr: T[], callback: (item: T) => void) {
for (let i = 0; i < arr.length; i++) {
callback(arr[i]);
}
}
3. 제네릭 인터페이스 & 타입 별칭
3-1. 제네릭 인터페이스
인터페이스 이름 뒤에 <T> 등을 붙여 정의한다.
interface KeyPair<K, V> {
key: K;
value: V;
}
// ⚠️ 주의: 변수 정의 시 반드시 타입을 명시해야 함
let keyPair: KeyPair<string, number> = {
key: "key",
value: 0,
};
3-2. 인덱스 시그니처와 함께 사용
interface Map<V> {
[key: string]: V;
}
let booleanMap: Map<boolean> = {
key: true,
};
3-3. 제네릭 타입 별칭
인터페이스와 동일하게 사용 가능하다.
type Map2<V> = {
[key: string]: V;
};
4. 실전 예제: 제네릭 인터페이스 활용
유저의 프로필(profile)이 상황에 따라 Student일 수도 있고 Developer일 수도 있다면? 제네릭을 이용해 중복 코드를 없애고 타입 안정성을 높일 수 있다.
interface Student {
type: "student";
school: string;
}
interface Developer {
type: "developer";
skill: string;
}
// 제네릭 인터페이스 정의
interface User<T> {
name: string;
profile: T;
}
// 학생만 이용 가능한 함수: 매개변수 타입을 User<Student>로 제한
function goToSchool(user: User<Student>) {
const school = user.profile.school; // 타입 좁히기 없이 바로 접근 가능!
console.log(`${school}로 등교 완료`);
}
함수 내부에서 if문으로 타입을 좁힐 필요가 없어져 코드가 훨씬 깔끔해진다.
5. 제네릭 클래스
클래스도 제네릭을 사용하면 하나의 클래스로 다양한 타입의 데이터를 처리할 수 있다.
class List<T> {
constructor(private list: T[]) {}
push(data: T) {
this.list.push(data);
}
print() {
console.log(this.list);
}
}
// 생성자 인수로 타입 추론 가능
const numberList = new List([1, 2, 3]); // T는 number
const stringList = new List(["a", "b"]); // T는 string
제네릭이 없다면 NumberList, StringList 클래스를 따로 만들어야 했을 것이다.
6. 프로미스(Promise)와 제네릭
비동기 처리에 사용되는 Promise는 제네릭 클래스로 구현되어 있다.
성공했을 때(resolve) 반환되는 값의 타입을 제네릭으로 지정해 줄 수 있다.
// 1. 생성 시점에 타입 지정
const promise = new Promise<number>((resolve, reject) => {
setTimeout(() => {
resolve(20);
}, 3000);
});
promise.then((response) => {
// response는 number 타입 (20)
console.log(response);
});
함수의 반환값으로 사용할 때는 다음과 같이 명시하는 것이 직관적이다.
interface Post {
id: number;
title: string;
content: string;
}
// 반환값 타입에 Promise<Post> 명시
function fetchPost(): Promise<Post> {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve({
id: 1,
title: "게시글 제목",
content: "게시글 본문",
});
}, 3000);
});
}
참고: reject로 전달되는 에러 값의 타입은 정의할 수 없으며, 기본적으로 unknown (또는 any)으로 처리된다.
요약
- 함수 응용: 다중 타입 변수, 배열 처리,
extends를 이용한 타입 제한 등 다양하게 활용 가능. - 인터페이스/타입 별칭: 객체의 프로퍼티 타입을 유연하게 변경하며 재사용성 극대화.
- 클래스: 하나의 클래스로 여러 타입의 데이터를 처리하는 범용 클래스 생성.
- Promise: 비동기 작업의 결과값 타입을 제네릭으로 정의하여 안전하게 처리.
