제네릭 타입이란?
제네릭 타입은 특정 데이터 타입에 국한되지 않는 유연한 코드를 작성할 수 있도록 도와주는 도구입니다. 함수, 클래스, 인터페이스 등에 제네릭을 사용하면 호출 시점에 타입을 지정할 수 있어 유연성과 타입 안전성, 재사용성을 동시에 얻을 수 있는 강력한 기능입니다.
제네릭 타입 사용하기
제네릭의 기본 문법
제네릭은 함수나 클래스에서 타입을 매개변수처럼 취급합니다. 제네릭 타입은 자유롭게 작명이 가능하지만 일반적으로 Type의 약자인 T를 사용하며 다음 타입은 U, V 등을 사용합니다.
function merge<T, U>(objA: T, objB: U) {
return Object.assign({}, objA, objB);
// Object.assign(target, ...sources) 객체를 합쳐주는 메서드
}
const mergedObj = merge({ name: '빵빵이' }, { age: 25 });
console.log(mergedObj); // { name: '빵빵이', age: 25 }
console.log(mergedObj.age); // ✅ 정상적으로 25 출력
아래 코드와 비교하면 매개변수와 반환 값의 타입을 어떻게 설정하느냐에 따라 코드의 유연성과 타입 안전성에 차이가 나는 것을 볼 수 있습니다.
function merge(objA: object, objB: object) {
return Object.assign({}, objA, objB)
}
const mergedObj = merge({name: '빵빵이'}, {age: 25});
console.log(mergedObj); // {name: '빵빵이', age: 25}
mergedObj.age; // ❌ 에러 발생: 'age' 속성에 접근할 수 없음!
objA와 objB는 타입이 object로 선언되었습니다. object는 객체임을 보장하지만 내부 구조에 대한 정보는 알 수 없습니다. 2번째 코드의 경우 반환 값의 타입도 object로 추론되기 때문에 age와 같은 속성에 접근할 수 없습니다.
하지만 제네릭을 사용하면 merge함수가 호출될 때 매개변수의 구체적인 타입을 자동으로 추론합니다. T와 U는 각각
{name: string}과 {age: number}로 추론되며 반환 타입은 T & U로 합쳐집니다. 그로 인해 타입스크립트는 정확한 타입을 알고 속성에 더 안전하게 접근할 수 있습니다.
제약 조건
제네릭 타입은 어떤 타입이라도 허용하지만, 특정 조건을 추가할 수도 있습니다. 우선 첫 번째 예제 코드를 보겠습니다.
function merge<T, U>(objA: T, objB: U) {
return Object.assign({}, objA, objB);
}
const mergedObj = merge({name: '김옥지', hobbies: ['sports']}, 30);
console.log(mergedObj); // {name: '김옥지', hobbies: Array(1)}
Object.assign() 메서드는 객체를 합쳐주는 메서드입니다. 우리는 T와 U는 어떤 타입이던 상관이 없지만 객체이기는 해야 하는 상황입니다. 하지만 merge의 두 번째 인자에 number 타입의 30을 입력하고 컴파일을 한다면 그대로 실행됩니다. (오류가 잡히지 않음) 이럴 때 필요한 게 제약 조건입니다.
function merge<T, U extends object>(objA: T , objB: U) {
return Object.assign({}, objA, objB);
}
const mergedObj = merge({name: '김옥지', hobbies: ['sports']}, 30); // Argument of type 'number' is not assignable to parameter of type 'object'.
console.log(mergedObj);
Keyof로 제약 조건 추가
function extractAndConvert<T extends object, U extends keyof T>(obj: T, key: U) {
return `Value: ${obj[key]}`;
}
console.log(extractAndConvert({ name: '옥지' }, 'name')); // Value: 옥지
// console.log(extractAndConvert({ name: '옥지' }, 'age')); // 오류 발생
제네릭 클래스
제네릭을 클래스에 적용하면 특정 데이터 타입만 처리하도록 제한할 수 있습니다.
class DataStorage<T> {
private data: T[] = [];
addItem(item: T) {
this.data.push(item);
}
removeItem(item: T) {
this.data.splice(this.data.indexOf(item), 1);
}
getItems() {
return [...this.data];
}
}
// 사용 예시
const textStorage = new DataStorage<string>();
textStorage.addItem('빵빵이');
textStorage.addItem('옥지');
textStorage.removeItem('빵빵이');
console.log(textStorage.getItems()); // ['옥지']
제네릭의 참조 타입 문제
제네릭을 사용할 때 참조 타입의 문제가 있습니다.
class DataStorage<T> {
private data: T[] = [];
addItem(item: T) {
this.data.push(item);
}
removeItem(item: T) {
this.data.splice(this.data.indexOf(item), 1);
}
getItem() {
return [...this.data];
}
}
const objStorage = new DataStorage<object>();
objStorage.addItem({name: '빵빵이'});
objStorage.addItem({name: '김옥지'});
objStorage.removeItem({name: '빵빵이'});
console.log(objStorage.getItem()); // 0 : {name: '빵빵이'}
위 코드를 보면 정상적으로 보일 수 있지만 정상적으로 작동되는 코드가 아닙니다. 우리는 '빵빵이'를 지우고 싶은데 이상하게 '빵빵이'가 지워지지 않고 마지막 요소인 '김옥지'가 지워지는 모습입니다. 그 이유는 우리가 객체를 다루고 있기 때문입니다. 객체는 참조 타입으로 원시값을 다루는 로직은 비 원시 값(객체, 배열 등)을 제대로 다루지 못합니다. 위 코드에서 우리가 추가한 '빵빵이'와 삭제하려는 '빵빵이'는 값은 같아 보이지만 메모리에서는 완전히 새로운 객체이고 다른 주소를 가집니다. 그로 인해서 indexOf에 객체를 전달하여 원하는 값을 찾기 못해 -1을 반환, 따라서 마지막 요소를 제거합니다.
해결방법 1. 추가 제거하려는 값을 변수에 담아 사용하기.
const objStorage = new DataStorage<object>();
const nameObj = {name: '빵빵이'}
objStorage.addItem(nameObj); //nameObj 같은 메모리 주소 사용용
objStorage.addItem({name: '김옥지'});
objStorage.removeItem(nameObj); // nameObj 같은 메모리 주소 사용
console.log(objStorage.getItem()); // 0 : {name: '김옥지'}
해결방법 2. 타입을 원시 값으로만 작동하게 하기.
class DataStorage<T extends string | number | boolean> {
private data: T[] = [];
addItem(item: T) {
this.data.push(item);
}
removeItem(item: T) {
if(this.data.indexOf(item) === -1) {
return;
}
this.data.splice(this.data.indexOf(item), 1);
}
getItem() {
return [...this.data];
}
}
const nameStorage = new DataStorage<string>();
nameStorage.addItem('빵빵이');
nameStorage.addItem('김옥지');
nameStorage.removeItem('빵빵이');
console.log(nameStorage.getItem()); // ['김옥지']
제네릭 타입과 유니온 타입의 차이점은?
제네릭 타입
- 타입을 동적으로 설정
- 사용자가 호출 시 원하는 타입을 설정하거나, 타입스크립트가 자동으로 추론합니다.
- 타입 안정성 보장
- 제네릭을 활용하면 반환값과 매개변수 간의 타입 관계를 유지할 수 있습니다.
- 다양한 타입을 다룰 수 있지만, 타입 정보는 유지되어 유연성과 안정성이 보장됩니다.
유니온 타입
- 작성한 여러 타입을 모두 허용합니다.
- 특정 타입이 무엇인지 정확히 알기 위해 타입 가드를 사용해야 할 때가 많습니다.
'TypeScript' 카테고리의 다른 글
[TypeScript] 타입스크립트의 인터페이스(interface) 알아보기 (0) | 2024.12.30 |
---|---|
[TypeScript] 타입스크립트의 기본 타입 (0) | 2024.12.26 |