[새싹 프론트엔드] 12/15 (TypeScript 제네릭 타입)
제네릭 타입
TypeScript는 정적 타입 언어이기 때문에 함수 또는 클래스를 정의하는 시점에 매개변수나 반환값의 타입을 선언하여야 한다. 그런데 함수 또는 클래스를 정의하는 시점에 매개변수나 반환값의 타입을 선언하기 어려운 경우가 있다.
만약 printArray 함수에 경우에 따라 숫자 타입, 문자열 타입, 불리언 타입... 각기 다른 종류의 배열이 전달된다고 할 때, 이 타입들을 전부 처리하기 위해서는 함수 선언부에 들어올 수 있는 타입들을 모두 명시해야 한다. 이런 경우, 제네릭 타입으로 선언하면 함수 선언부를 간단하게 작성 가능하다.
// 제네릭 타입을 쓰지 않으면 모든 경우의 수를 다 선언해야 한다
function printArray(arr: number[] | string[] | boolean[]): void {
console.log(arr);
}
any를 사용해서 처리할 수 있지만, any를 사용하면 TypeScript를 쓰는 의미가 없으므로... 쓰지 말자...
function 함수이름<T>(매개변수: T타입): 반환값타입 {
// 코드
}
function printArray<T>(arr: T[]): void {
console.log(arr);
}
// 타입 제약 조건을 사용
const printArrayArrow = <T extends []>(arr: T[]): void => {
console.log(arr);
};
const arr1 = [10, 20, 30];
printArrayArrow<number>(arr1);
const arr2 = ["a", "b", "c"];
printArray<string>(arr2);
// 함수 호출 시 타입 생략 가능
const arr3 = [true, false];
printArrayArrow(arr3);
+ 유데미 강의 시청 후 추가
function merge(objA: object, objB: object) {
return Object.assign(objA, objB);
}
const mergeObj = merge({ name: "Max" }, { age: 30 });
console.log(mergeObj.age) // Error!
타입스크립트는 merge함수가 객체를 반환한다는 것을 알 수는 있지만, 그 객체에 age라는 속성이 있는지 없는지는 모르기 때문에 에러가 나게 된다. 이 때 제네릭을 사용하여 해결할 수 있다.
function merge<T, U>(objA: T, objB: U) {
return Object.assign(objA, objB);
}
const mergeObj = merge({ name: "Max" }, { age: 30 });
console.log(mergeObj.age);
mergeObj는 name과 age의 인터셉션이라고 인식하기 때문에 age라는 속성이 있다는 것을 알고 있다는 의미가 된다. 따라서 mergeObj.age에 접근이 가능한 것.
제네릭이 아닐 때는 왜 추론하지 못하는가?
미상의 객체 두 개의 인터셉션은 또 다른 미상의 객체일 뿐이기 때문이다! 타입스크립트에 추가적인 정보를 더 줄 수 없는 것..!
제약 조건
하지만 이 경우, 두 번째 매개변수에 30 이라는 숫자만을 전달해주면 에러가 나지는 않지만 숫자 '타입' 이라는 것만으로도 입력이 되긴 한다. (콘솔에 출력하면 나오지는 않음. Object.assign은 객체끼리 묶는 것이므로...)
즉, T와 U가 어떤 객체인지는 상관이 없어도 일단은 항상 '객체'여야 한다는 것을 알려줄 필요가 있다.
따라서 타입 제약 조건을 통해 T와 U가 객체라는 것을 알려줄 수 있도록 구현해야 한다.
function merge<T extends object, U extends object>(objA: T, objB: U) {
return Object.assign(objA, objB);
}
const mergeObj = merge({ name: "Max" }, { age: 30 });
console.log(mergeObj.age);
'T extends 타입'을 통해 이 타입이 어떤 타입인지 알려주는 것. 타입 자리에는 string, number, union등 유연하게 들어갈 수 있다.
keyof 제약 조건
객체를 첫 번째 매개변수로 가지고, 두 번째 매개변수가 첫 번째 객체의 키값을 가지는 경우 사용할 수 있다. 즉, 두 번째 매개변수가 첫 번째 타입의 속성을 얻고자 할 때.
function extractAndConvert<T extends object, U extends keyof T>(
obj: T,
key: U
) {
return obj[key];
}
extractAndConvert({ name: "Max" }, "name");
두 번째 매개변수의 경우 첫 번째 객체의 키값을 가지고 있다는 것을 keyof 제약 조건을 통해 알려주고 있으므로 첫 번째 객체에 없는 키값을 입력하게 되면 에러가 난다.
제네릭 클래스
제네릭 클래스를 만드는 이유는 텍스트를 저장하지 않아야 할 수도 있고, 다른 데이터 스토리지에 숫자를 입력해야 할 수도 있을 것이기 때문이다.
class DataStorage<T extends string | number | boolean> {
private data: T[] = [];
addItem(item: T) {
this.data.push(item);
}
removeItem(item: T) {
// 배열에 마지막 요소가 없다면 return
if (this.data.indexOf(item) === -1) {
return;
}
this.data.splice(this.data.indexOf(item), 1); // -1
}
getItems() {
return [...this.data];
}
}
const textStorage = new DataStorage<string>();
textStorage.addItem('Max');
textStorage.addItem('Manu');
textStorage.removeItem('Max');
console.log(textStorage.getItems());
const numberStorage = new DataStorage<number>();
제네릭 유틸리티 타입
Partial | interface의 모든 프로퍼티를 선택적(optional)으로 변경한다.
interface CourseGoal {
title: string;
description: string;
date: Date;
}
function createCourseGoal(
title: string,
description: string,
date: Date
): CourseGoal {
return { title: title, description: description, date: date };
}
createCourseGoal 함수는 CourseGoal 타입을 가진 하나의 객체를 반환한다고 해보자. 이 중에서 선택적인 타입 속성으로 만들기 위해 Partial을 이용할 수 있다.
function createCourseGoal(
title: string,
description: string,
date: Date
): CourseGoal {
// 순차적으로 요소를 추가하며 선택적이기 때문에 모든 요소를 기입하지 않아도 된다.
let courseGoal: Partial<CourseGoal> = {};
courseGoal.title = title;
// courseGoal.description = description;
courseGoal.date = date;
return courseGoal as CourseGoal;
}
interface IPerson {
name: string;
age: number;
gender: string;
}
// 인터페이스의 모든 프로퍼티를 optional하게 변경한다.
type PartialPerson = Partial<IPerson>;
const partialPerson: PartialPerson ={
gender: "male" // optional
}
Required | interface의 모든 프로퍼티가 갖추어진 객체를 생성해야 한다.
type RequiredPerson = Required<IPerson>;
const requiredPerson: RequiredPerson = {
name: "Jade", // required
age: 29, // required
gender: "male", // required
};
Pick | interface의 프로퍼티 중 일부만 받도록 설정한다.
type PickPerson = Pick<IPerson, "name" | "age">;
const pickPerson: PickPerson ={
name: "Jade", // required
age: 29, // required
// gender: "male" --> (X)
}
Readonly | 읽기 전용
const names: Readonly<string[]> = ["Max", "Anna"];
인터페이스와 제네릭
// 기존의 인터페이스
interface UserInterface{
name: string;
age: number;
phone: number | string
}
// 제네릭 타입 사용
interface UserInterface<T> {
name: string;
age: number;
phone: T;
}
// 제네릭 타입을 사용한 인터페이스를 이용하여 객체 생성
const user: UserInterface <number> = {
name: "park",
age: 30,
phone: 821012345678,
};
클래스와 제네릭
인터페이스 없이 클래스만 생성했을 때
class User<T> {
constructor(public name: string, public age: number, public phone: T) {}
}
const user: User<number> = new User("soo", 20, 821012345678);
인터페이스를 클래스에 결합시켰을 때
interface UserInterface<T> {
name: string;
age: number;
phone: T;
}
class User<T> implements UserInterface<T> {
constructor(public name: string, public age: number, public phone: T) {}
}
const user: User<number> = new User("soo", 20, 821012345678);
클래스에 <T>를 붙여주고, implements 한 인터페이스는 이름 자체에 <T>가 들어가 있으니 결과적으로 클래스와 인터페이스 이름 뒤에 둘 다 <T>를 붙이고 있는 모습이다.
실습문제
const printArr = <T extends {}>(arr: T[]): T[] => {
return arr.reverse();
};
console.log(printArr<number>([1, 2, 3, 4, 5]));
console.log(printArr<string>(["a", "b", "c"]));
제네릭과 union의 사용 방식의 차이
특정 타입을 고정하거나, 생성한 전체 클래스 인스턴스에 걸쳐 같은 함수를 사용하거나, 전체 함수에 걸쳐 같은 타입을 사용하고자 할 때는 제네릭 타입이 유용.
유니언 타입은 모든 메소드 호출이나 모든 함수 호출마다 다른 타입을 지정하고자 하는 경우에 유용.
'새싹DT 기업연계형 프론트엔드 실무 프로젝트 과정 9주차 블로그 포스팅'