본문 바로가기
TYPESCRIPT

제네릭

by 일태찡 2023. 3. 27.

 

제네릭이란?

 

제네릭 타입은 다른 타입과 연결되는 종류인데 다른 타입이 어떤 타입이어야 하는지에 대해서는 크게 상관하지 않습니다.

 

배열

 

 

배열은 그 자체로 타입이지만 배열에 특정 타입의 데이터를 저장할 수 있습니다.

배열 타입은 어떤 타입의 데이터가 저장되든 상관하지 않으며 저장하는 요소가 문자열 목록, 숫자형 목록, 객체 목록, 혼합된 데이터의 목록이든 상관하지 않지만 적어도 정보가 저장되는 것인지에 대해서는 확인을 합니다.

 

const names: string[] = [];

 

아래의 코드는 위 코드와 정확히 같은 기능을 수행합니다.

 

const names: Array<string> = [];

 

문자열 목록이 들어온다 가정하면 이렇게 코드를 구성할 수 있으며 이 배열이 제네릭 타입인 것입니다.

 

 

프로미스

 

const promise = new Promise((resolve, reject) => {
    setTimeout(() => {
        resolve('This is done!')
    }, 2000)
});

 

이렇게 프로미스 객체를 생성하는 코드를 작성하면 다음과 같이 이 프로미스가 무언가를 성공(resolve)을 시킬 때 그 타입을 정확히 알고 있지 못합니다.

 

 

따라서 다음과 같이 이 프로미스가 문자열을 반환할 것이라고 제네릭 타입을 사용하여 구현할 수 있습니다.

 

const promise: Promise<string> = new Promise((resolve, reject) => {
    setTimeout(() => {
        resolve('This is done!')
    }, 2000)
});

 

제네릭 타입을 사용하면 타입스크립트에게 정보를 줄 수 있습니다.

이 프로미스가 결국 문자열이나 숫자를 반환할 것이라는 정보를 주는 것이기에 보다 나은 타입 안전성을 확보할 수 있습니다.

 

const promise: Promise<string> = new Promise((resolve, reject) => {
    setTimeout(() => {
        resolve('This is done!')
    }, 2000)
});

promise.then(data => {
    data.toUpperCase(); // 에러 아님
    data * 2; // 에러
})

 

위 설명의 예시는 위와 같으며 문자열이 반환될 것이라고 선언했기에 문자열 처리는 가능하지만 숫자 처리는 불가능합니다.

이처럼 제네릭 타입 정보를 사용하여 수행할 수 있는 작업에는 큰 유연성이 있습니다.

 

 

제네릭 함수

 

두 객체를 합치는 함수를 만들고 시작합니다.

 

function merge(objA: object, objB: object) {
    return Object.assign(objA, objB);
};

const mergedObj = merge({name: 'KIM'}, {age: 30});
mergedObj.name; // 에러

 

위 코드에서 mergedObj가 name 속성을 가지고 있는 걸 우리는 알고 있음에도 타입스크립트는 이를 잡아내지 못하고 에러가 나옵니다.

 

 

다음을 해결하기 위해 제네릭 함수로 만들어줘야 합니다.

 

function merge<T, U>(objA: T, objB: U) {
    return Object.assign(objA, objB);
};

const mergedObj = merge({name: 'KIM'}, {age: 30});
mergedObj.age // 에러 아님

 

다음과 같이 함수 뒤에 <> 안에 T와 U타입을 넣고 매개변수에 하나씩 설정해 반환 값을 그 둘의 인터섹션 타입이 되게 설정합니다.

T와 U는 이 제네릭 함수의 인자 타입 설정을 위한 관행적인 알파벳 설정입니다.

 

 

이렇게 되면 인자에 어떤 타입이 들어와도 받아들일 수 있고 그 반환 타입은 그 두 타입의 인터섹션 타입입니다.

이는 함수를 호출 시에 결정이 됩니다.

 

 

제약 조건 작업하기

 

function merge<T, U>(objA: T, objB: U) {
    return Object.assign(objA, objB);
};

const mergedObj = merge({name: 'KIM'}, 30);

 

위 코드에서 두 번째 인자를 객체가 아닌 숫자로 바꿨지만 에러를 잡지 못하고 조용히 사라지게 됩니다.

이를 해결하기 위해 두 매개변수가 적어도 객체임은 보장해야 하는 작업이 필요합니다.

 

function merge<T extends object, U extends object>(objA: T, objB: U) {
    return Object.assign(objA, objB);
};

const mergedObj = merge({name: 'KIM'}, 30); // 에러

console.log(mergedObj);

 

이렇게 T와 U 타입이 어떤 구조를 가지든 아무 객체가 되어도 상관없지만 일단은 객체임을 보장하게 만들 수 있습니다.

작성하고 나면 30만 적힌 부분에 다음과 같은 에러를 볼 수 있습니다.

 

 

 

일반적인 적용

 

인자에 따라 반환 결과가 다른 함수를 만들고 시작합니다.

 

function countAndDescribe<T>(element: T) {
    let descriptionText = 'Got no value.';
    if (element.length === 1) {
        descriptionText = 'Got 1 elements.'
    } else if (element.length > 1) {
        descriptionText = 'Got' + element.length + ' elements.';
    }
    return [element, descriptionText];
};

 

여기서 조건문에 있는 length 속성에서 다음과 같은 에러가 나게 됩니다.

 

 

인자에 뭐가 들어올 수 있을지 알 수 없기 때문입니다.

 

interface Lengthy {
    length: number;
}

function countAndDescribe<T extends Lengthy>(element: T) {
    let descriptionText = 'Got no value.';
    if (element.length === 1) {
        descriptionText = 'Got 1 elements.'
    } else if (element.length > 1) {
        descriptionText = 'Got' + element.length + ' elements.';
    }
    return [element, descriptionText];
};

 

이와 같이 인터페이스를 통해 매개 변수에 제약 조건을 추가하여 얻는 것이 무엇이든 length 속성도 반환되며 배열이나 문자열은 length 속성을 지닌다는 것을 알 수 있습니다.

이처럼 보다 유연한 작업이 요구될 때 제네릭 유형을 사용하면 제약 조건 덕분에 정확한 타입에 대해 신경 쓰지 않을 수 있습니다.

그저 length 속성이 있는지만 신경 쓰면 되기 때문입니다.

 

 

keyof 제약 조건

 

첫 번째 매개 변수 객체의 두 번째 매개변수 속성 값을 찾는 함수를 만들고 시작합니다.

 

function extractAndConvert(obj: object, key: string) {
    return 'Value: ' + obj[key]; // 에러
}

extractAndConvert({}, 'name');

 

이렇게 작성하면 다음과 같은 에러가 나옵니다.

이는 두 번째 매개변수 키 값이 첫 번째 객체에 있는지 확실치 않기 때문에 생겨난 것입니다.

 

 

function extractAndConvert<T extends object, U extends keyof T>(obj: T, key: U) {
    return 'Value: ' + obj[key];
}

extractAndConvert({name: 'KIM'}, 'name');

 

이렇게 제네릭 타입을 작성하여 타입스크립트에게 첫 번째 매개변수가 모든 유형의 객체여야 하고 두 번째 매개변수는 해당 객체의 모든 유형의 키여야 한다고 알려주는 것입니다.

그래서 다음과 같이 객체 안에 name 속성이 있어야 에러 없이 진행할 수 있습니다.

 

 

제네릭 클래스

 

class DataStorage {
    private data = [];

    addItem(item) {
        this.data.push(item);
    }

    removeItem(item) {
        this.data.splice(this.data.indexOf(item), 1)
    }

    getItem() {
        return [...this.data]
    }
}

 

위와 같이 클래스를 작성하면 다음과 같은 에러가 매개변수 item이 적힌 부분에 등장합니다.

 

 

class DataStorage<T extends number | string | boolean> {
    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 textStorage = new DataStorage<string>();
const numberStorage = new DataStorage<number>();

textStorage.addItem('GOOD')
textStorage.addItem(0) // 에러
numberStorage.addItem('BAD') // 에러
numberStorage.addItem(100)

 

그래서 위와 같이 클래스와 메서드의 매개 변수를 제네릭 타입으로 연결하여 해결했습니다.

또 이 클래스 내의 로직은 원시 값에만 해당하므로 제약 조건도 추가했습니다.

 

 

제네릭 유틸리티 타입

 

파셜 타입

 

interface CourseGoal {
    title: string,
    description: string,
    completeUntil: Date
}

function createCourseGoal(
    title: string,
    description: string,
    date: Date
    ): CourseGoal {
    let courseGoal: CourseGoal = {}; // 에러
    courseGoal.title = title;
    courseGoal.description = description;
    courseGoal.completeUntil = date;
    return courseGoal;
}

다음과 같이 코드를 작성하면  courseGoal에서 다음과 같은 에러가 발생합니다.

 

 

이는 처음에 빈 객체가 할당되었기 때문에 다음에 단계적으로 속성을 추가했어도 해결되지 않았습니다.

이때 파셜 타입을 사용하여 모든 속성이 선택적인 객체 타입으로 바꿀 수 있습니다.

 

interface CourseGoal {
    title: string,
    description: string,
    completeUntil: Date
}

function createCourseGoal(
    title: string,
    description: string,
    date: Date
    ): CourseGoal {
    let courseGoal: Partial<CourseGoal> = {};
    courseGoal.title = title;
    courseGoal.description = description;
    courseGoal.completeUntil = date;
    return courseGoal as CourseGoal;
}

 

따라서 중괄호 쌍으로 빈 객체를 설정하여 단계적으로 모든 요소를 추가할 수 있습니다.

마지막엔 courseGoal이 파셜 타입이기 때문에 CourseGoal 타입으로 형 변환 해주면 됩니다.

 

 

Readonly

 

const names: Readonly<string[]> = ['KIM', 'PARK'];
names.push('JUNG') // 에러

 

여기에 문자열의 배열을 저장하는데 이 문자열의 배열은 읽기만 가능하다고 알려주는 것입니다.

 

이런 유틸리티 타입들은 타입스크립트에만 존재하므로 다른 언어로 컴파일하면 사라집니다.

특정 상황에서 여러 유틸리티 타입을 유용하게 사용하면 더 나은 코드를 만들 수 있습니다.

모든 유틸리티 타입은 아래에서 확인할 수 있습니다.

https://www.typescriptlang.org/ko/docs/handbook/utility-types.html

 

Documentation - Utility Types

Types which are globally included in TypeScript

www.typescriptlang.org

 

 

제네릭 타입 vs 유니언 타입

 

제네릭 타입

class DataStorage<T extends number | string | boolean> {
    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]
    }
}

 

유니언 타입

class DataStorage {
    private data: string[] | number[] | boolean[] = [];

    addItem(item: string | number | boolean) {
        this.data.push(item);
    }

    removeItem(item: string | number | boolean) {
        this.data.splice(this.data.indexOf(item), 1)
    }

    getItem() {
        return [...this.data]
    }
}

 

위 제네릭 클래스를 유니언 타입을 이용하여 위와 같이 구현할 수 도 있을 것처럼 보입니다.

하지만 여기서 data에 특정 타입이 설정되었다 하더라도 메서드의 매개변수에서는 어떤 타입이라도 받아들이게 됩니다.

결국 이 유니언 타입을 이용한 클래스는 사용하여 작업할 때마다 한 가지 타입을 결정하고 그 타입을 고수해야 한다고 입력한 것이 아니라 메서드를 호출할 때마다 이 타입들 중 하나를 자유롭게 사용할 수 있다고 입력한 것입니다.

 

정리하자면 유니언 타입은 모든 메서드 호출이나 모든 함수 호출마다 다른 타입을 지정하고자 하는 경우에 유용합니다.

제네릭 타입은 특정 타입을 고정하거나 생성한 전체 클래스 인스턴스에 걸쳐 같은 함수를 사용하거나 전체 함수에 걸쳐 같은 타입을 사용하고자 할 때 유용합니다.

 

 

레퍼런스

 

https://www.typescriptlang.org/ko/docs/handbook/2/generics.html

 

Documentation - Generics

Types which take parameters

www.typescriptlang.org

 

'TYPESCRIPT' 카테고리의 다른 글

연습 프로젝트 1  (6) 2023.03.30
데코레이터  (8) 2023.03.28
고급 타입  (6) 2023.03.24
인터페이스  (7) 2023.03.23
클래스 2  (6) 2023.03.21