타입스크립트 - 제네릭

드엔트론프·2023년 8월 4일
0

typescript

목록 보기
9/12
post-thumbnail

들어가며

  • 타입스크립트에서 개인적으로 이해하기 어려웠던 부분중 하나가 제네릭이다. 그만큼 어렵지만 타입 정의할 때 꽤나 자주 쓰이고 특히 라이브러리나 기존 정의된 타입들에 대부분 쓰인다고 볼 수 있다.
  • 제네릭과 몇몇 개념을 조금 익히고 실제로 미리 정의된 타입을 보면 어떻게 동작하는지 이해할 수 있고, 만들어볼 수도 있다.

제네릭

타입을 마치 함수의 파라미터처럼 사용하는 것

  • 코딩을 하다보면 반복적인 코드를 줄이는 일을 자주하게 되는데, 제네릭이 딱 그렇다. 한 번의 선언을 통해 여러 타입을 만족하게 해준다.
/**
 * 첫번째 사례
 * 두 매개변수의 타입이 다를 때는 제네릭을 여러 개 써줄 수 있다.
 */

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

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

/**
 * 두번째 사례
 */

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

let num = returnFirstValue([0, 1, 2]);
let str = returnFirstValue([1, "hello", "my"]);
  • 두번째 사례에서 str에 마우스를 올리면 number | string 의 타입으로 표기된다.
  • 이유는 T[ ]에 data[0]가 뭐가 올 지 모르니까 그런것이다.
  • 그런데 나는 data[0]의 값이 뭐가 올 지 명확하게 정해주고 싶으면 어떻게할까?
function returnFirstValue<T>(data: [T, ...unknown[]]) {
  return data[0];
}

let num = returnFirstValue([0, 1, 2]);
let str = returnFirstValue([1, "hello", "my"]);
  • data의 타입이 튜플이고, 첫 번째 요소의 타입이 T이다. 나는 첫 번째 data의 타입만 알면되고 나머지는 알 필요가 없으니 Rest Parameter로 표기하여 unknown 타입의 배열이 들어올것 같아 라고 표기해주는 것이다.
/**
 * 세번째 사례
 */

function getLength<T>(data: T){
  return data.length //오류 'T' 형식에 'length' 속성이 없습니다.ts(2339)
}

let var1 = getLength([1,2,3]);
let var2 = getLength("12345")
let var3 = getLength({length: 10})
  • 이렇게 제네릭을 설정하면 오류가 발생한다.
  • 어떻게 수정해야할까?
function getLength<T extends { length: number}>(data: T){
  return data.length
}

let var1 = getLength([1,2,3]);
let var2 = getLength("12345")
let var3 = getLength({length: 10})
  • <T extends { length: number}>
    • length가 number인 프로퍼티를 가지고 있는 객체를 확장하는 타입이라 표기해준다. 이는 인터페이스를 생각해보면 이해하기 쉽다.

      interface A {
      	length: number;
      }
      
      interface B extends A {}
      // B는 A를 확장했기에, 무조건 length 프로퍼티를 갖고 있어야한다.

제네릭으로 map, forEach 함수 직접 만들어 타입 정의하기

map

  • 기본적으로 map 은 아래와 같이 사용한다.
const arr = [1, 2, 3];
const newArr = arr.map((it) => it * 2); // [2, 4, 6]
  • 타입을 쓰지 않았을 때 아래와 같은 형태로 구현할 수 있다.
function map(arr, callback) {
  let result = [];
  for (let i = 0; i < arr.length; i++) {
    result.push(callback(arr[i]));
  }
  return result;
}
  • 여기에 타입을 정의해주려면 어떻게 해야할까?
map(arr, (it) => it * 2);

정의한 map을 보면 it 는 arr의 타입에 따라 간다. arr가 number 배열이라면 it는 number이고, arr가 string 배열이라면 it는 string인 것이다.

function map<T>(arr : T[], callback : (item : T) => T) {
  let result = [];
  for (let i = 0; i < arr.length; i++) {
    result.push(callback(arr[i]));
  }
  return result;
} 
  • 이렇게 제네릭 T로 정의하면 에러가 다 사라진다. 이렇게 정의하면 될까?
  • 다음의 예를 보자.
map(["hi,", "hello"], (it) => parseInt(it));
  • map 메서드는 string 배열을 받지만, it는 number에서 쓰이는 parseInt 메서드를 사용한다.
  • 이렇게 되면 다시 빨간줄이 가게 된다.
  • 이를 해결하기 위해서, it의 반환값은 똑같은 T가 아닌 추가적인 제네릭이 필요하다.
function map<T, U>(arr : T[], callback : (item : T) => U) {
  let result = [];
  for (let i = 0; i < arr.length; i++) {
    result.push(callback(arr[i]));
  }
  return result;
} 

forEach

  • map 과 비슷하게 forEach를 정의해보자.
function forEach<T>(arr: T[], callback: (item: T) => void) {
  for (let i = 0; i < arr.length; i++) {
    callback(arr[i]);
  }
}
  • forEach함수는 item 하나하나 돌며 값을 출력해주기 때문에(마치 기본 for문 같이), item의 반환값은 void로 지정해줘도 된다.
  • 다행히 map을 미리 해봤기 때문에 더 어려움이 없게 느껴진다.

인터페이스와 제네릭 ( + 타입별칭)

  • 인터페이스에서 제네릭을 사용하는 기본형태는 아래와 같다.
/**
 * 제네릭 인터페이스
 */

// K, V와 같은 것을 "타입변수"라고 부르고, 문서에 따라
// "타입 파라미터", "제네릭 타입 변수", "제네릭 타입 파라미터"라 불리기도 한다.
interface KeyPair<K, V> {
  key: K;
  value: V;
}

// 제네릭 인터페이스를 타입으로 사용할 땐, 타입 변수에 할당할 타입을 꺽쇄와 함께 타입을 작성해야한다.
let keyPair: KeyPair<string, number> = {
  key: "key",
  value: 1,
};

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

/**
 * 인덱스 시그니쳐
 */

interface NumberMap {
  [key: string]: number;
}

let numberMap1: NumberMap = {
  key: 444,
};

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

let stringMap: Map<string> = {
  key: "value",
};

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

/**
 * 제네릭 타입 별칭
 */

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

let stringMap2: Map2<string> = {
  key: "hello",
};
  • 이러한 제네릭과 인터페이스를 활용해보자.

제네릭 인터페이스 활용하기

  • 유저관리 프로그램을 만든다고 해보자.
  • 유저 구분 : 학생 유저 / 개발자 유저
interface Student {
  type: "student"
  school: string; 
}

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

interface User {
  name: string;
  profile : Student | Developer
}
  • 이렇게 구분해주면 아래와 같이 사용할 것이다.
const developerUser : User = {
  name: "kang",
  profile : {
    type: "developer",
    skill: "TypeScript"
  }
}

const studentUser : User = {
  name: 'kim',
  profile: {
    type: "student",
    school: "blahblah"
  }
}
  • 여기에 만약 학생일 때 학교 간다는 함수가 있다고 가정해보자.
function goToSchool(user: User){
  if(user.profile.type !== 'student'){
    console.log('wrong')
    return;
  }
  const school = user.profile.school;
  console.log(`${school} 등교 완료`)
}
  • 타입 좁히기를 통해, 빨간줄 없이 해결할 수 있다.
  • 그런데, 만약 구분되는 종류가 너무 많아져 각각의 함수가 필요하다면? 각각의 함수마다 타입 좁히기를 다 적어줘야하나? 이걸 제네릭을 활용해보자.
  • 먼저, User 인터페이스에 제네릭을 붙여준다.
interface User<T> {
  name: string;
  profile : T
}
  • 그럼 미리 User를 받게 정의했던 함수와 변수에서 에러가 발생한다. 이는 정의한 제네릭에 어떤 타입이 들어올 지 꺽쇄라 정의해줘야하기 때문이다.
function goToSchool(user: User<Student>) {
  const school = user.profile.school;
  console.log(`${school} 등교 완료`);
}

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

const studentUser: User<Student> = {
  name: "kim",
  profile: {
    type: "student",
    school: "blahblah",
  },
};
  • goToSchool 함수의 타입 좁히기가 필요없게 됐다 !

제네릭으로 클래스 활용하기

  • number타입의 배열을 받는 클래스가 있다고 하자.
  • 앞서 배웠듯 constructor에 접근 제어자(public, private 등)를 쓰면 굳이 this.~~ 를 작성해주지 않아도 된다.
class NumberList {
  constructor(private list: number[]) {}

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

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

  print() {
    console.log(this.list);
  }
}
  • 위 클래스는 아래와 같이 활용될 수 있을 것이다.
const numberList = new NumberList([1, 2, 3]);
numberList.pop();
numberList.push(4);
numberList.print();
  • 그런데 만약, stringList도 쓰고 싶다면? 똑같이 class 를 복사해서 list: string[] 으로 바꿔주고 해야될까? 아니다. 제네릭을 활용하면 된다 !
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 = new List([1, 2, 3]);
numberList.pop();
numberList.push(4);
numberList.print();

const stringList = new List(["wow", "good"]);
  • 참고로 제네릭 인터페이스 쓸 때처럼 const stringList = new List<string>(["wow", "good"]); 처럼 적을 필요가 없다. 인수에 들어가는 배열로 Constructor에서 타입 추론을 하기 때문.

프로미스와 제네릭

  • 프로미스에 타입을 정의해보자.
  • 실제 new 생성자로 만들어지는 Promise는 다음과 같이 정의되어있다.
    new <T>(executor: (resolve: (value: T | PromiseLike<T>) 
      => void, reject: (reason?: any) => void) => void): Promise<T>;
  • resolve는 제네릭을 따라가지만, reject는 any로 되어있다.

  • 예를들어 Post 하는 함수가 있다고 하자. 인터페이스는 다음과 같이 정의되어있다.

interface Post {
  id: number;
  title: string;
  content: string;
}
  • 그리고 fetchPost 함수는 아래와 같다.
function fetchPost(){
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve({
        id: 1,
        title: "amazing Title",
        content: "amazing Content",
      });
    }, 3000);
  });
}
  • 실제 호출이 없기에 setTimeout을 사용했고, resolve 되면 안의 객체가 들어가는 형태로 보자.
  • fetchPost 를 통해 then으로 id를 받으려하면? 에러가 발생한다.
const postRequest = fetchPost()

postRequest.then((post) => {
  post.id //Error
})

promise error

  • 비동기처리 시 따로 타입처리를 해주지 않으면 결과값은 unknown으로 추론된다. 그렇기에 id에 대한 에러가 발생하는것.
  • 해결방법은 2가지 방법이 있다.

1. new Promise에 타입작성하기

    function fetchPost() {
      return new Promise<Post>((resolve, reject) => {
        setTimeout(() => {
          resolve({
            id: 1,
            title: "amazing Title",
            content: "amazing Content",
          });
        }, 3000);
      });
    }

2. 함수 반환값 타입에 작성하기
- 함수의 선언 부분만 보고도 어떤 상태를 반환하는지를 알 수 있기에 함수 반환값에 정의하는 것을 더 추천한다.

   function fetchPost() : Promise<Post> {
     return new Promise((resolve, reject) => {
       setTimeout(() => {
         resolve({
           id: 1,
           title: "amazing Title",
           content: "amazing Content",
         });
       }, 3000);
     });
   }
profile
왜? 를 깊게 고민하고 해결하는 사람이 되고 싶은 개발자

0개의 댓글