<Demopeu/>

제네릭(Generic)

제네릭(Generic)

referencetypescriptgenerictype-variable

📌 제네릭의 등장 이유

function func(value:any) {
    return value;
}

func("hello");
let num = func(123);
num.toUpperCase(); // ❌ Error: 타입 검사에서는 오류가 없지만 런타임에서 터져버림
func(true);

단순히 매개변수를 반환하는 함수인데, any를 사용하면 TypeScript를 쓰는 이유가 없다. 그리고 타입 검사에서는 오류가 없지만 런타임에서 터져버리는 문제가 발생한다. 그렇다면 unknown을 사용하면 어떨까?

function func(value:unknown) {
    return value;
}

func("hello");
let num = func(123);
num.toUpperCase(); // ❌ Error: 'num'의 타입은 'unknown'이므로 'toUpperCase' 속성을 사용할 수 없습니다.
func(true);

분명 number타입을 넘겨줬지만, unknown으로 선언했기 때문에 toUpperCase를 사용할 수 없다. 그렇다고 매번 타입 좁히기를 할 수도 없는 노릇이다.

이러한 문제를 해결하기 위해 제네릭이 등장했다.

📌 제네릭의 기본 문법

function func<T>(value: T): T {
    return value;
}

generic은 일단 타입의 값을 받고, 받는 순간 결정하는 범용적인 타입이라고 생각하면 편하다.

  1. <T> : 제네릭을 선언하는 문법. 이때 T는 타입의 이름으로, 사용자가 원하는 이름으로 변경할 수 있다. 이걸 타입 변수라고 한다.
  2. 그리고 필요한 경우, 매개변수와 반환값에 지정한 타입 변수를 사용할 수 있다.
  3. 제네릭 함수를 호출할 때 타입 변수에 직접 명시하는 것도 가능하다.
    func<string>("hello");
    

📌 타입 변수의 사용법

1. 타입 변수를 매개변수로 사용할 때

function swap<T>(a: T, b: T) {
    return [b, a];
}

let result = swap('1', 2); // ❌ Error: 'number' 형식의 인수는 'string' 형식의 매개변수에 할당될 수 없습니다.

이럴 경우에는 여러 개의 타입 변수를 사용하면 된다.

function swap<T, U>(a: T, b: U) {
    return [b, a];
}

let result = swap('1', 2); // ✅ Ok

2. 타입 변수를 배열/튜플 타입에 사용할 때

function returnFirst<T>(data: T) {
    return data[0]; // ❌ Error: '0' 형식의 식을 'unknown' 인덱스 형식에 사용할 수 없으므로 요소에 암시적으로 'any' 형식이 할당됩니다.
}

let result = returnFirst([1, 2, 3]);

이럴 경우, 아래와 같이 사용하면 된다.

function returnFirst<T>(data: T[]) {
    return data[0];
}

let result = returnFirst([1, 2, 3]);

그런데, 만약 타입이 혼합이면?

function returnFirst<T>(data: T[]) {
    return data[0];
}

let result = returnFirst(['1', 2, 3]); // result의 타입은 'string | number'가 된다.

우리는 유니온 타입이 아니라 진짜 타입을 알고 싶은 것이다. 따라서,

function returnFirst<T>(data: [T, ...unknown[]]) {
    return data[0];
}

let result = returnFirst(['1', 2, 3]); // result의 타입은 'string'이 된다.

이런 특이한 문법으로 해결할 수 있다.

3. 타입 변수에 제약(extends) 걸 때

function getLength<T extends { length: number }>(data: T) {
    return data.length;
}

let result1 = getLength([1, 2, 3]);
let result2 = getLength("hello");
let result3 = getLength({ length: 1 });
let result4 = getLength(123); // ❌ Error: 'number' 형식의 인수는 '{ length: number; }' 형식의 매개변수에 할당될 수 없습니다.

map 메서드 타입 확인하기

interface Array<T> {
    map<U>(callbackfn: (value: T, index: number, array: T[]) => U, thisArg?: any): U[];
}

this를 엄격하게 쓰지는 않는다. 요즘은 대부분 thisArg를 사용하지 않기 때문이라고 생각.

📌 제네릭 인터페이스와 제네릭 클래스

interface Box<T> {
    value: T;
}

let box1: Box = { // ❌ Error: 'Box<T>' 제네릭 형식에 1형식 인수가 필요합니다.
    value: "hello"
}

let box2: Box<string> = { // ✅ Ok
    value: "hello"
}

🎯 실무에서 이런 상황에 유용하다

interface Student {
    type: "student",
    school: string;
}

interface Teacher {
    type: "teacher",
    skill: string;
}

interface Person {
    name: string;
    profile: Student | Teacher;
}

function goToSchool(person: Person) {
    if (person.profile.type !== "student") {
        console.log("잘못된 프로필 입니다.");
        return;
    }

    console.log(person.profile.school);
}

이런식으로, 타입 좁히기를 하면 되지만, 만약 Person의 profile이 점점 많아진다면 어떻게 될까? 이럴 때 필요한 것이 제네릭 인터페이스이다.

interface Student {
    type: "student",
    school: string;
}

interface Teacher {
    type: "teacher",
    skill: string;
}

interface Person<T> {
    name: string;
    profile: T;
}

function goToSchool(person: Person<Student>) {
    console.log(person.profile.school);
}

타입을 좁히지 않더라도 사용할 수 있게 된다.

클래스에서도 사용법은 동일하다.

class Box<T> {
    constructor(public value: T[]) { }
    push(item: T) {
        this.value.push(item);
    }
    pop() {
        return this.value.pop();
    }
    print() {
        console.log(this.value);
    }
}

const numberBox = new Box<number>([]);
numberBox.push(1);

📌 프로미스 객체와 제네릭

const promise = new Promise((resolve, reject) => {
    setTimeout(() => {
        resolve(20);
    }, 1000)
})

promise.then((result) => {
    console.log(result * 10); // ❌ Error: 'result'은(는) 'unknown' 형식입니다.
});

애초에 Promise 타입을 까보면,

interface Promise<T> {
}

new <T>(executor: (resolve: (value: T | PromiseLike<T>) => void, reject: (reason?: any) => void) => void): Promise<T>;

이런식으로 실패했을 때 타입은 정할 수 없다. 따라서,

const promise = new Promise<number>((resolve,reject)=>{
    setTimeout(()=>{
        resolve(20);
        // or
    reject("error");
    },1000)
})

promise.then((result)=>{
    console.log(result * 10);
})
promise.catch((error)=>{
    if (typeof error === "string") {
        console.log(error);
    }
})