📢 어렵고 정석적인 개념 설명보다는 저같은 초보자도 이해하기 쉽게 정리하는 것을 원칙으로 포스팅하고 있습니다. 😄

[TypeScript] 타입 변수로 타입을 받아오는 제네릭

제네릭이란?

타입스크립트에서 제네릭이란 타입을 선언 당시 바로 정해주는 게 아니라 타입 변수라는 것을 둬서 매개변수의 타입이 타입 변수의 타입에 들어가는 일반적인 타입 추론 방식을 의미한다. 이를 통해 함수, 클래스, 인터페이스 등을 작성할 때 다양한 타입에 유연하게 대응할 수 있고, 코드의 재사용성도 높일 수 있다.

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

func(123); // T는 number 타입
func('abc'); // T는 string 타입

함수 이름 옆에 <T>라고 되어 있는 게 바로 타입 변수이다. 타입 변수란 말 그대로 타입을 저장할 수 있는 변수이다.

이제 함수를 호출할 때 들어오는 매개변수의 타입에 따라서 T에 들어오는 타입이 유동적으로 변하게 된다. 예를 들어 func(123)의 경우 매개변수의 타입이 number이므로 타입 변수 T는 number가 된다.

(주로 타입 변수의 이름으로 'T'를 사용한다. 하지만, 이것이 절대적인 것은 아니며 다른 알파벳이나 의미있는 이름을 사용하기도 한다.)

 

그동안 타입을 먼저 지정해주고, 그 타입에 맞춰서 변수나 함수를 선언해주었다면 제네릭은 호출되는 값의 타입에 따라 유동적으로 타입을 지정해준다. 매개변수를 사용하는 함수에서 각각의 타입을 개별적으로 선언하는 것은 매우 비효율적이다. 이에 대응하여 제네릭을 적용하면 매개변수의 타입에 유연하게 대응할 수 있어 코드의 효율성을 높일 수 있다.

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

func([1, 2, 3]); // T는 number[] 타입
func<[number, number, number]>([1, 2, 3]); // T는 튜플 타입

함수 호출 부분에서 제네릭 꺽쇠를 열어 타입 변수에 타입을 직접 정할 수도 있다. 예를 들어, func([1, 2, 3])의 경우 자동으로 타입 변수 Tnumber 배열 타입으로 추론된다. 만약, number 배열 타입이 아닌 튜플 타입으로 선언해주고 싶을 때는 함수 호출 부분에서 꺽쇠를 열어 직접 타입을 지정해주면 된다.

 

여러 개의 타입 변수 사용하기

// 무조건 a에는 number, b에는 string 타입이 들어가야 함
function func(a: number, b: string) {
    return [a, b];
}

// 타입을 미리 정하지 않았기 때문에, T와 U 각각 다른 타입 아무거나 들어올 수 있음
function func<T, U>(a: T, b: U) {
    return [a, b];
}

만약 매개변수의 타입이 여러 개라면 타입 변수 또한 여러 개 만들어주면 된다. 위 코드에서 제네릭을 사용하지 않고 매개변수에 타입을 미리 지정해준 경우에는 number와 string 2개의 타입만 쓸 수 있다. 하지만, 제네릭의 타입 변수를 활용하면 number, string이든 boolean, number[]이든 상관없이 서로 다른 2개의 타입이 들어올 수 있다.

 

타입 변수에도 ...rest를 이용할 수 있다.

function func<T>(arr: T[]) {
    return arr[0];
}

let arr = func([1, '2', 3]); // T는 number | string의 union 타입

// 만약 arr[0]의 타입을 가져오고 싶다면? 튜플로 바꿔주기!
function func<T>(arr: [T, ...unknown[]]) {
    return arr[0];
}

let arr2 = func([1, '2', 3]); // T는 number 타입

함수의 인수 부분에 union 타입의 값이 들어오는 경우 타입 변수는 union 타입이 된다. 하지만, 위의 코드처럼 특정 인덱스가 리턴되는 경우에는 굳이 union 타입이 아닌 리턴되는 값의 타입을 넣어줘도 된다.

 

예시 코드의 상황처럼 0번 째 인덱스 타입을 가져오고 싶다면, [T, ...unknown[]]튜플 형식으로 가져오면 된다.

0번 째 인덱스에 T를 놓고, 나머지는 rest 처리를 함으로써 타입 변수 T에 0번 째 인덱스의 타입이 들어가고 나머지는 unknown 타입으로 추론한다.

 

특정 프로퍼티가 있는 타입으로 제한해주기

function func<T>(data: T) {
    return data.length; // number 타입일 경우 오류 발생
}

// length 프로퍼티가 있는 타입만 T로 들어오게 하고 싶을 때
function func<T extends { length: number }>(data: T) {
    return data.length;
}

length의 경우 숫자 타입에는 없고, 배열이나 문자 타입에는 존재하는 프로퍼티이다. 만약, 타입 변수 T에 숫자 타입이 들어오고 리턴값으로 length 프로퍼티를 리턴한다면 어떻게 될까? 바로 오류가 발생할 것이다.

따라서, 타입 변수에 아무 타입이나 들어와도 되지만 특정 프로퍼티를 가지고 있는 타입만 들어오게 하고 싶을 때 확장(extends)를 통해 타입 변수를 제한할 수도 있다.

 

인터페이스 확장을 생각해보면, interface A extends B일 때 A 인터페이스에는 B의 프로퍼티가 상속됐었다. 이런 원리와 똑같이 타입 변수에도 A extends B를 하면 B의 프로퍼티가 A에 상속되는 것이므로 B 프로퍼티를 가지고 있는 A 타입이 되는 것이다. (T extends { length: number }는 T인데 length 프로퍼티를 가지고 있는 T가 된다.)

 

인터페이스에 제네릭 사용하기

interface Person<T, U> {
    name: T;
    address: U;
}

let user: Person<string, number> = {
    name: 'kim',
    address: 15043
}

let user2: Person<boolean, number[]> = {
    name: true,
    address: ['1', '2']
}

제네릭을 인터페이스에서도 활용할 수 있다. 제네릭을 이용하면 객체의 타입을 지정할 때 일일이 모든 객체 타입 유형에 맞게 인터페이스를 선언할 필요가 없다. 객체의 프로퍼티 개수가 모두 같은 상태라면 하나의 인터페이스에 타입 변수를 이용해 쉽게 타입을 지정해주면 된다.

// 인덱스 시그니쳐에도 활용 가능
interface Map<V> {
    [key: string]: V;
}

let strMap: Map<number> = {
    aaa: 123,
    bbb: 456,
    ccc: 789
}

인덱스 시그니쳐에도 활용이 가능하다. (사용 방법은 일반적인 제네릭 방식과 같으므로 설명 생략)

interface Student {
    type: 'student';
    school: string;
}

interface Developer {
    type: 'developer';
    skill: string;
}

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

// T 자리에 Developer가 들어간다.
let developerUser: User<Developer> = {
    name: 'shawn',
    profile: {
        type: 'developer',
        skill: 'typescript'
    }
}


function goToSchool(user: User<Student>) {
    const school = user.profile.school;
    console.log(school);
}

제네릭으로 타입이 들어갈 수도 있지만, 인터페이스 자체가 들어갈 수도 있다.

User 인터페이스의 profile의 타입을 타입 변수 T로 지정해놓고 변수 developerUser에서 인터페이스 User의 타입 변수로 Developer 인터페이스를 넣어주었다. 그럼, User 인터페이스의 T 타입 변수에는 Developer 인터페이스가 들어가져서 profile 안에는 type과 skill 프로퍼티의 타입이 생기게 된다.

 

함수 goToSchool()의 경우 만약 user.pofile.school이 없다면 오류가 발생할 것이다.

따라서, 이것을 방지하기 위해 매개변수 user에 타입을 User로 주고 타입 변수에 Student 인터페이스를 줘버리면 무조건 Student 인터페이스가 들어오기 때문에 (school 프로퍼티가 100% 있음) 오류가 발생하지 않는다.

 

클래스에 제네릭 사용하기

class List<T> {
    constructor(private list: T[]) {}

    push(data: T) {
        this.list.push(data);
    }

    pop() {
        return this.list.pop();
    }

    print() {
        console.log(this.list);
    }
}

const numberList: List<number> = new List([1, 2, 3]);
const stringList: List<string> = new List(['1', '2', '3']);

클래스에서도 제네릭을 활용할 수 있다. 타입 자리에 타입 변수를 놓고, 호출할 때 타입 변수 자리에 타입을 적어주면 된다.

 

Promise에서 제네릭 사용하기

interface Post {
    id: number;
    title: string;
    content: string;
}

function fetchPost(): Promise<Post> {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            resolve({
                id: 1,
                title: '제목',
                content: '내용'
            })
        }, 3000)
    })
}

const postRequest = fetchPost();
postRequest.then(post => console.log(post.id));
// catch의 err는 any 타입이므로 typeof를 이용해 타입을 좁혀준다.
postRequest.catch(err => {
    if (typeof err === 'string') {
    	console.log(err);
    }
})

프로미스에서 제네릭을 사용할 때는 별도로 타입 변수를 생성해주지 않아도 된다. (Promise<T> 이런 것처럼..)

타입스크립트 자체에서 이미 프로미스 타입이 정의되어 있기 때문에 우리는 타입 변수 자리에 타입만 넣어주면 된다.

보통은 Promise<타입> 형식으로 Promise 옆에 타입을 넣어서 사용하며 이 타입은 resolve로 반환됐을 때의 값의 타입이기도 하다.

 

그럼 reject 되었을 때의 타입은 어디서 정해줄까? reject의 error의 타입은 자동으로 any 타입으로 추론되며 주로 string 타입의 에러 메세지이기 때문에 위 코드처럼 typeof를 통해 string 타입으로 좁혀주기도 한다.

 

1. 제네릭은 타입을 파라미터로 받아서 타입을 추론하는 것을 의미한다.
2. 함수에서 주로 사용되며, 인터페이스, 클래스, 타입 별칭 등에서도 사용된다.
3. function abc<T>(value: T): T {...} : <T>로 타입 변수 생성, 매개변수 value의 타입과 리턴값의 타입을 T로 지정.
    abc(123) : 123은 number 타입이므로 T에 number가 할당됨.