본문 바로가기

온라인 강의(유데미, 인프런 등)/한 입 크기로 잘라먹는 타입스크립트(인프런)

[한 입 크기로 잘라먹는 타입스크립트] 제네릭

반응형

1. 제네릭 소개

제네릭이란 함수나 인터페이스, 타입 별칭, 클래스 등을 다양한 타입과 함께 동작하도록 만들어 주는 기능이다. 제네릭 함수는 두루두루 모든 타입의 값을 다 적용할 수 있는 그런 범용적인 함수이다.

다음과 같이 제네릭 함수를 선언할 수 있다.

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

let num = func(10);
// number 타입

함수 이름 뒤에 꺽쇠를 열고 타입을 담는 변수인 타입 변수 T를 선언하고 파라미터와 반환값의 타입을 T로 설정한다.

T에 어떤 타입이 할당될 지는 함수가 호출될 때 결정된다. func(10) 처럼 Number 타입의 값을 아규먼트로 전달하면 파라미터 value에 Number 타입의 값이 저장되면서 T가 Number 타입으로 추론된다. 그럼 이때의 func 함수의 반환값 타입또한 Number 타입이 된다.

제네릭 함수를 호출할 때 다음과 같이 타입 변수에 할당할 타입을 직접 명시하는 것도 가능하다.

1. T에 [Number, Number, Number] 튜플 타입이 할당됨
2. 파라미터 value와 반환값 타입이 모두 튜플 타입이 됨


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

let arr = func<[number, number, number]>([1, 2, 3]);

만약 위 코드에서 타입 변수에 할당할 타입을 튜플 타입으로 설정하지 않았다면 T가 number[] 타입으로 추론 된다. 타입스크립트는 타입을 추론할 때 항상 일반적이고 좀 더 범용적인 타입으로 추론하기 때문이다. 이렇듯 타입 변수에 할당하고 싶은 특정 타입이 존재한다면 함수 호출과 함께 꺽쇠를 열고 직접 명시해주면 된다.

 

2. 타입 변수 응용하기

1) 사례 1

만약 2개의 타입 변수가 필요한 상황이라면 다음과 같이 T, U 처럼 2개의 타입 변수를 사용해도 된다.

// T는 String 타입으로 U는 Number 타입

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

const [a, b] = swap("1", 2);

2) 사례 2

다양한 배열 타입을 아규먼트로 받는 제네릭 함수를 만들어야 한다면 다음과 같이 할 수 있다.

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

// 첫번째 호출에서는 아규먼트로 Number[] 타입의 값을 전달했으므로 이때의 T는 Number 타입으로 추론된다. 
// 이때의 함수 반환값 타입은 Number 타입이 된다.
let num = returnFirstValue([0, 1, 2]);
// number

// 두번째 호출에서는 아규먼트로 (String | Number)[] 타입의 값을 전달했으므로 이때의 T는 String | Number 타입으로 추론된다. 
// 이때의 함수 반환값 타입은 String | Number 타입이 된다.
let str = returnFirstValue([1, "hello", "mynameis"]);
// number | string

함수 매개변수 data의 타입을 T[]로 설정했기 때문에 배열이 아닌 값은 아규먼트로 전달할 수 없게 된다. 배열을 아규먼트로 전달하면 T는 배열의 요소 타입으로 할당된다.

3) 사례 3

위 사례에서 만약 반환값의 타입을 배열의 첫번째 요소의 타입이 되도록 하려면 다음과 같이 튜플 타입과 나머지 파라미터를 이용한다.

//T는 첫번째 요소의 타입인 Number 타입이 된다. 따라서 함수 반환값 타입또한 Number 타입이 된다.

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

let str = returnFirstValue([1, "hello", "mynameis"]);
// number

함수 파라미터의 타입을 정의할 때 튜플 타입을 이용해 첫번째 요소의 타입은 T, 나머지 요소의 타입은 …unknown[] 으로 길이도 타입도 상관 없도록 정의한다.

4) 사례 4

마지막으로 살펴볼 사례는 타입 변수를 제한하는 사례이다. 타입 변수를 제한한다는 것은 함수를 호출하고 아규먼트로 전달할 수 있는 값의 범위에 제한을 두는 것을 의미한다.

다음은 타입 변수를 적어도 length 프로퍼티를 갖는 객체 타입으로 제한한 예시이다.

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

getLength("123");            // ✅

getLength([1, 2, 3]);        // ✅

getLength({ length: 1 });    // ✅

getLength(undefined);        // ❌

getLength(null);             // ❌

타입 변수를 제한할 때에는 확장(extends)을 이용한다.

위와 같이 T extends { length : number } 라고 정의하면, T는 이제 { length : number } 객체 타입의 서브 타입이 된다. 바꿔말하면 이제 T는 무조건 Number 타입의 프로퍼티 length 를 가지고 있는 타입이 되어야 한다는 것이다. 따라서 이렇게 extends를 이용해 타입 변수를 제한하면 아래와 같은 결과가 나타납니다.

  • 1번 호출은 인수로 length 프로퍼티가 존재하는 String 타입의 값을 전달 했으므로 허용
  • 2번 호출은 인수로 length 프로퍼티가 존재하는 Number[] 타입의 값을 전달 했으므로 허용
  • 3번 호출은 인수로 length 프로퍼티가 존재하는 객체 타입의 값을 전달 했으므로 허용
  • 4번 호출은 인수로 undefined을 전달했으므로 오류
  • 5번 호출은 인수로 null을 전달했으므로 오류

 

3. map, forEach 메서드 타입 정의하기

1) map

map 메서드는 원본 배열 타입과 다른 타입의 배열로도 변환할 수 있어야 한다.

const arr = [1, 2, 3];

function map<T>(arr: T[], callback: (item: T) => T): T[] {
  (...)
}

map(arr, (it) => it.toString()); // ❌

따라서 타입 변수를 하나 더 추가해 다음과 같이 수정한다.

const arr = [1, 2, 3];

function map<T, U>(arr: T[], callback: (item: T) => U): U[] {
  (...)
}

map(arr, (it) => it.toString());
// string[] 타입의 배열을 반환
// 결과 : ["1", "2", "3"]

2) forEach

function forEach<T>(arr: T[], callback: (item: T) => void) {
  for (let i = 0; i < arr.length; i++) {
    callback(arr[i]);
  }
}

 

4. 제네릭 인터페이스, 제네릭 타입 별칭

제네릭은 인터페이스에도 적용할 수 있다. 다음과 같이 인터페이스에 타입 변수를 선언해 사용하면 된다.

interface KeyPair<K, V> {
  key: K;
  value: V;
}
키페어를 저장하는 객체의 타입을 제네릭 인터페이스로 정의했다. 다음과 같이 변수의 타입으로 정의하여 사용할 수 있다.
let keyPair: KeyPair<string, number> = {
  key: "key",
  value: 0,
};

let keyPair2: KeyPair<boolean, string[]> = {
  key: true,
  value: ["1"],
};

제네릭 인터페이스는 제네릭 함수와는 달리 변수의 타입으로 정의할 때 반드시 꺽쇠와 함께 타입 변수에 할당할 타입을 명시해주어야 한다.제네릭 함수는 파라미터에 제공되는 값의 타입을 기준으로 타입 변수의 타입을 추론할 수 있지만, 인터페이스는 마땅히 추론할 수 있는 값이 없기 때문이다.

# 인덱스 시그니쳐와 함께 사용하기

제네릭 인터페이스는 인덱스 시그니쳐와 함께 사용하면 다음과 같이 기존보다 훨씬 더 유연한 객체 타입을 정의할 수 있다.

interface Map<V> {
  [key: string]: V;
}

//변수 stringMap의 타입을 Map<string> 으로 정의했다. 따라서 V가 string 타입이 되어 
// 이 변수의 타입은 key는 string이고 value는 string인 모든 프로퍼티를 포함하는 객체 타입으로 정의된다.
let stringMap: Map<string> = {
  key: "value",
};

let booleanMap: Map<boolean> = {
  key: true,
};

한개의 타입 변수 V를 갖는 제네릭 인터페이스 Map을 정의했다. 이 인터페이스는 인덱스 시그니쳐로 key의 타입은 string, value의 타입은 V인 모든 객체 타입을 포함하는 타입이다.

# 제네릭 타입 별칭

인터페이스와 마찬가지로 타입 별칭에도 역시 제네릭을 적용할 수 있다.

type Map2<V> = {
  [key: string]: V;
};

let stringMap2: Map2<string> = {
  key: "string",
};

제네릭 타입 별칭을 사용할 때에도 제네릭 인터페이스와 마찬가지로 타입으로 정의될 때 반드시 타입 변수에 설정할 타입을 명시해 주어야 한다.

# 제네릭 인터페이스 활용 예

개발자 또는 학생이 이용하는 어떤 프로그램이 있다고 가정해보자.

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

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

interface User {
  name: string;
  profile: Student | Developer;
}

function goToSchool(user: User<Student>) {
  if (user.profile.type !== "student") {
    console.log("잘 못 오셨습니다");
    return;
  }

  const school = user.profile.school;
  console.log(`${school}로 등교 완료`);
}

const developerUser: User = {
  name: "이정환",
  profile: {
    type: "developer",
    skill: "typescript",
  },
};

const studentUser: User = {
  name: "홍길동",
  profile: {
    type: "student",
    school: "가톨릭대학교",
  },
};

학생을 의미하는 Student와 개발자를 의미하는 Developer 타입, 이 두 타입 모두 String Literal 타입의 type 프로퍼티를 갖고 있으며, 서로소 유니온 타입이다.

위 코드는 겉으로 보았을때에는 지금 당장은 별 문제가 없어보이지만 학생만 할 수 있는 기능이 점점 많아진다고 가정하면 매번 기능을 만들기 위해 함수를 선언할 때 마다 조건문을 이용해 타입을 좁혀야 하기 때문에 결국 매우 불편해 질 것이다. 게다가 타입을 좁히는 코드는 중복 코드가 될 것이다.

이럴 때 바로 제네릭 인터페이스를 이용하면 좋다. 

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

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

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

function goToSchool(user: User<Student>) {
  const school = user.profile.school;
  console.log(`${school}로 등교 완료`);
}

const developerUser: User<Developer> = {
  name: "이정환",
  profile: {
    type: "developer",
    skill: "TypeScript",
  },
};

const studentUser: User<Student> = {
  name: "홍길동",
  profile: {
    type: "student",
    school: "가톨릭대학교",
  },
};

그럼 이제 goToSchool 함수의 파라미터 타입을 User<Student> 처럼 정의해 학생 유저만 이 함수의 아규먼트로 전달하도록 제한할 수 있다. 결과적으로 함수 내부에서 타입을 좁힐 필요가 없어지므로 코드가 훨씬 간결해진다.

 

5. 프로미스와 제네릭

Promise는 제네릭 클래스로 구현되어 있어 새로운 Promise를 생성할 때 다음과 같이 타입 변수에 할당할 타입을 직접 설정해 주면 해당 타입이 바로 resolve 결과값의 타입이 된다.

const promise = new Promise<number>((resolve, reject) => {
  setTimeout(() => {
    // 결과값 : 20
    resolve(20);
  }, 3000);
});

promise.then((response) => {
  // response는 number 타입
  console.log(response);
});

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

reject 함수에 아규먼트로 전달하는 값, 즉 실패의 결과값 타입은 정의할 수 없다. 그냥 unknown 타입으로 고정되어 있기 때문에 catch 메서드에서 사용하려면 타입 좁히기를 통해 안전하게 사용해야 한다.

만약 어떤 함수가 Promise 객체를 반환한다면 함수의 반환값 타입을 위해 다음과 같이 할 수 있다.

function fetchPost() {
  return new Promise<Post>((resolve, reject) => {
    setTimeout(() => {
      resolve({
        id: 1,
        title: "게시글 제목",
        content: "게시글 본문",
      });
    }, 3000);
  });
}

// 이 방식이 협업에 더 좋다.
// 이 함수는 Promise로 Post 타입을 반환한다는 뜻이된다.
function fetchPost(): Promise<Post> {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve({
        id: 1,
        title: "게시글 제목",
        content: "게시글 본문",
      });
    }, 3000);
  });
}

 

https://www.inflearn.com/course/%ED%95%9C%EC%9E%85-%ED%81%AC%EA%B8%B0-%ED%83%80%EC%9E%85%EC%8A%A4%ED%81%AC%EB%A6%BD%ED%8A%B8/dashboard

 

한 입 크기로 잘라먹는 타입스크립트 - 인프런 | 강의

문법을 넘어 동작 원리와 개념 이해까지 배워도 배워도 헷갈리는 타입스크립트 이제 제대로 배워보세요! 여러분을 타입스크립트 마법사🧙🏻‍♀️로 만들어드립니다., - 강의 소개 | 인프런

www.inflearn.com

 

반응형