Front-End/TypeScript

타입스크립트[TypeScript] | 제네릭(Generic)이란? + filter, map 함수 구현해보기

jaeyeong 2023. 4. 19. 15:10

 

해당 글은 타입스크립트 프로그래밍 책과 유데미 타입스크립트 강의를 공부하여 정리한 글입니다.

수정해야 할 부분이 있다면 언제든지 알려주세요!

 

제네릭(Generic)

지금까지 boolean, string, number 등에 대해 알아보았었는데, 이러한 타입을 구체 타입이라고 한다.

구체 타입은 변수에 전달 될 타입을 정확하게 알고 있으며 실제로 이 타입이 전달되었는지 확인할 때 사용된다.

그러면 이와 반대로 어떤 타입이 사용될지 미리 알 수 없을 때 는 어떻게 해야 할까?

 

바로 제네릭(Generic)을 이용해서 해결할 수 있다.

함수를 정의하기 위해 타입을 정의해야 한다고 하자.

매개변수의 타입을 정의할 때 지금은 어떤 타입이 올지 알 수 없고, 누군가 함수를 호출 또는 정의할 때 TS가 전달된 매개변수를 통해

타입을 알아서 추론하도록 하는 것이 제네릭이다.

 

다음은 제네릭을 사용하지 않고 filter 함수를 정의한 코드이다.

type Filter = {
    (array: number[], f: (item: number) => boolean) => number[]
    (array: string[], f: (item: string) => boolean) => string[]
}

function filter(array, f) {
    let result = [];
    for (let i = 0; i<array.length; i++) {
        let item = array[i];
        if (f(item)) {
            result.push(item);
        }
    }
    return result;
}

const answer = filter([1,2,3,4], _ => _ < 3);
console.log(answer); // [1, 2]

 

type을 오버로드 하여 정의하였는데, 이렇게 코드를 작성한다면 더 많은 타입이 입력되는 경우

직접 하나하나 추가해야 하며 코드가 길어질 위험이 있다.

 

제네릭을 사용하여 다음과 같이 수정할 수 있다.

type Filter = {
    <T>(array: T[], f: (item: T) => boolean): T[]
}

const filter: Filter = (array, f) => {
    let result = [];
    for (let i = 0; i<array.length; i++) {
        let item = array[i];
        if (f(item)) {
            result.push(item);
        }
    }
    return result;
}

const answer = filter([1,2,3,4], _ => _ < 3);
const answer2 = filter(['a', 'b', 'c'], _ => _ === 'b');
console.log(answer); // [1, 2]
console.log(answer2); // ['b']

 

타입스크립트는 전달된 array의 타입을 확인한 뒤 T의 타입을 추론한다.

T의 타입이 추론되면 정의된 모든 T를 추론한 타입으로 대입하게 된다.

 

TS는 이러한 T를 타입을 매개변수화 한다고 하여 제네릭 타입 매개변수라고 부르고 있다.

(제네릭 타입 매개변수 = 제네릭 타입, 다형성 타입 매개변수, 제네릭)

꺽쇠괄호(<>)를 사용하여 제네릭 타입 매개변수라는 것을 선언한다.

 

책에서는 제네릭을 사용함으로써 코드를 일반화하고, 재사용성을 높이고, 간결하게 유지하는 데 도움을 주기 때문에

가능하면 제네릭을 사용할 것을 권장하고 있다.

 

헷갈렸던 점

책에서는 위 예시를 설명하며 <T>를 호출 시그니처의 일부로 선언했으므로

TS는 Filter 타입의 함수를 실제 호출할 때 구체 타입을 T로 한정한다고 알려주었다.

 

그리고 이와 다르게 T의 범위를 Filter의 타입 별칭으로 한정하려면

Filter를 사용할 때 타입을 명시적으로 한정하게 해야 한다고 하며 아래 코드를 보여주었다.

type Filter<T> = {
    (array: T[], f: (item: T) => boolean): T[]
}

const filter: Filter<number> = (array, f) => {
    let result = [];
    for (let i = 0; i<array.length; i++) {
        let item = array[i];
        if (f(item)) {
            result.push(item);
        }
    }
    return result;
}

 

나는 이 두 개의 코드의 차이점이 명확하게 와닿지 않아서

"T의 범위를 Filter의 타입 별칭으로 한정하려면 Filter를 사용할 때 타입을 명시적으로 한정하게 해야 한다"라는

설명을 이해하는데 어려움을 겪었다.

 

chatGPT에도 물어보고 책을 여러 번 읽은 결과 이해를 했고, 정리해보려고 한다.

 

일단 첫 번째 코드와 두 번째 코드의 큰 차이점은 함수 타입 선언 방식이다.

첫번째 코드는 제네릭 타입 매개변수인 <T>객체 타입에 포함시켰고, 두번째 코드는 함수 타입에 포함시켰다.

// 객체 타입에 포함
type Filter = {
    <T>(array: T[], f: (item: T) => boolean): T[]
}

// 함수 타입에 포함
type Filter<T> = {
    (array: T[], f: (item: T) => boolean): T[]
}

 

객체 타입에 포함시키는 경우엔 타입 변수 T를 사용하여 어떤 타입이 와도 타입스크립트가 추론을 하여

다양한 타입을 사용할 수 있다는 것을 의미한다.

 

반면에 함수 타입에 포함시키는 경우엔 Filter를 사용하여 함수를 정의할 시

타입 변수 T에 명시적으로 타입을 전달하여 특정 타입에 대해서만 함수를 실행시킬 수 있다는 것을 의미한다.

 

즉, Filter<number>는 특정 타입에 대해서만 적용 가능하며, Filter는 모든 타입에 적용 가능하다.

 

그래서 첫 번째 코드의 경우 Filter 타입만 전달해도 TS가 알아서 타입을 추론하여 실행시켜 주는데,

두 번째 코드는 무조건 특정 타입을 명시적으로 전달해야 실행이 가능하다.

 

아래 코드 블록은 타입이 정해지는 과정이다.

// 첫번째 코드
type Filter = {
    <T>(array: T[], f: (item: T) => boolean): T[]
}

const filter: Filter = (array, f) => {...} // 전달 되는 array의 타입에 따라 T의 타입을 추론
filter([1,2,3,4], _ => _ < 3); // [1, 2] -> array는 number[]이므로 T는 number가 됨

// 두번째 코드
type Filter<T> = {
    (array: T[], f: (item: T) => boolean): T[]
}

let filter: Filter<number> = (array, f) => {...} // 명시적으로 전달한 number 타입이 T의 타입이 됨
let filter: Filter = (array, f) => {...} // Error: 제네릭 타입 'Filter'는 한 개의 타입 인수를 요구함

 

결론은, 첫 번째 코드는 함수를 호출할 때 T를 구체 타입으로 한정하여 각각의 filter 호출에 따라 각각의 T 한정 값을 가지며,

두 번째 코드는 Filter 타입의 함수를 선언할 때 T를 한정한다.

 

첫 번째 코드를 type 별칭이 아닌, 함수를 정의하면서 제네릭을 사용한다면 다음과 같다.

function filter<T>(array: T[], f: (item: T) => boolean): T[] {...}

 

map() 제네릭으로 구현해 보기

function map(array: unknown[], f: (item: unknown) => unknown): unknown[] {
    let result = [];
    for (let i = 0; i<array.length; i++) {
        result[i] = f(array[i]);
    }
    return result
}

 

책에서 위 코드를 제네릭을 사용한 코드로 변경해 보라고 미니 퀴즈를 내줬고, 나는 이렇게 작성했다.

function map<T>(array: T[], f: (item: T) => T): T[] {
    let result = [];
    for (let i = 0; i<array.length; i++) {
        result[i] = f(array[i]);
    }
    return result
}

 

테스트하기 위해 map([1,2,3], (item) => item * 2); 코드를 실행시켜 보았고,

정상적으로 값이 출력되어 정답이라고 생각했다.

하지만 정답을 보니 다음과 같이 두 개의 제네릭 매개변수를 사용한 것을 확인했다.

function map<T,U>(array: T[], f: (item: T) => U): U[] {
    let result = [];
    for (let i = 0; i<array.length; i++) {
        result[i] = f(array[i]);
    }
    return result
}

 

제네릭 매개변수가 왜 두 개나 필요한지 이해가 안 됐는데, 이것 저것 테스트 출력을 해보다가

map([1,2,3], (_) => _ + '2'); 이 코드를 실행시켜 보니 왜 두 개가 필요한지 확실히 알게 되었다.

 

map 메서드는 반환 값의 타입이 입력 값의 타입과 달라질 수 있다는 점을 간과하고 있었다.

내가 작성한 코드로 실행시키면 'string' 유형은 'number' 유형에 할당할 수 없습니다. 오류가 발생하는 것을 확인했다.

 

제네릭을 사용할 때 입력값과 반환값을 정확히 아는 것이 중요하다는 것을 알게 되었다.