타입스크립트와 복잡한 제네릭

‍이준성·2023년 5월 5일
0

개요

타입스크립트의 제네릭에 대해서 설명하는 글들은 많지만, 실제로 보게되면 모두 기초적인 개념만을 설명하고, 복잡한 경우를 다루는 경우는 찾기 어려웠다.
그래서 예시와 함께 내가 얻은 것들을 공유하고자 한다.
아래의 모든 내용들은 타입스크립트 컴파일 옵션에서 strict를 활성화 시켰을 때가 기준이다.

기본적인 제네릭

유틸리티 타입

막상 제네릭 등을 사용하고 싶어도, 공부하기 위한 문서가 부족함을 느끼게 된다.
나는 내장 유틸리티 타입에 대한 코드들이야말로 최고의 문서라고 생각한다. 참고할만한 내용들이 굉장히 많다.

type Pick<T, K extends keyof T> = {
    [P in K]: T[P];
};

위 내용은 기본 내장 Pick 의 정의이다. 설령 제네릭에 대한 지식이 부족하더라도, 위 예시만을 통해서도 많은 것을 알 수 있다.

  1. keyof 키워드가 존재한다
  2. extends 키워드가 존재한다
  3. in 키워드가 존재한다
  4. 타입 T에 대해서 P라는 키를 사용해서 T[P] 라는 것에 접근 가능하다

실제로 사용해보면서, 어떠한 의미인지 확인해보자.

interface User {
  id: number;
  name: string;
  birth: Date;
  password: string;
}
const correctUsage: Pick<User, 'name' | 'birth'> = {
  name: 'kim',
  birth: new Date(),
};
const inCorrectUsage: Pick<User, 'name' | 'birth'> = {
  name: 'kim',
  birth: new Date(),
  password: '1', // Not in Pick<User, 'name' | 'birth'>
};

Pick은 이름처럼 정해진 키값들만 뽑아서 새로운 타입을 만드는 개념이다.
이렇게 사용해보면서, extends-keyof의 쓰임을 알 수 있다.
또한, 분명 [P in K]로 하나의 키만 정의되었음에도, 여러 키값이 사용 가능한 것으로 볼 수 있다.
이를 통해 in은 반복문과 유사하게 쓸 수 있음도 알 수 있다.

이 외에도 수많은 제네릭 타입들이 정의되어있으니, 이들을 참고하면서 타입스크립트의 타입에 대해서 공부한다면 많은 도움이 될 것이다.
유틸리티 타입의 용례는, 공식 문서에서 찾아볼 수 있다.
유틸리티 타입의 실제 정의는 깃허브를 통해 확인하거나, 에디터에서 직접 눌러서 타고 들어갈 수 있다.

복잡한 제네릭 사용 예시

실제로 겪었던 필요 사항과, 그를 해결하기 위해 사용한 제네릭에 대한 코드를 간략화해서 설명하려고 한다.

인자에 따라 리턴타입이 달라지는 제네릭

다중 sns 로그인의 예시

백엔드 개발을 하면서, 앱에서 유저가 sns 로그인을 하면, 그 토큰을 받아서 유저의 프로필을 받아와야하는 일이 있었다.
또, 지원하는 sns의 종류는 여러가지였다. 당연히, sns의 종류에 따라서 호출해야하는 api는 서로 달랐다. 네이버는 네이버의 api를, 카카오는 카카오의 api를 호출하는 식이었다.
이를 외부에서 하나의 함수만을 사용해서 접근할 수 있도록 만들고 싶었다.

import axios, { AxiosPromise } from 'axios/index';

export enum AccountType {
  KAKAO = 'kakao',
  NAVER = 'naver',
  FACEBOOK = 'facebook',
}

export class NaverResponse {
  naver: string;
}

export class KakaoResponse {
  kakao: string;
}

export class FacebookResponse {
  facebook: string;
}

export const callFaceBookApi = (token: string) =>
  axios.get('facebook') as AxiosPromise<FacebookResponse>;
export const callNaverApi = (token: string) =>
  axios.get('naver') as AxiosPromise<NaverResponse>;
export const callKakaoApi = (token: string) =>
  axios.get('kakao') as AxiosPromise<KakaoResponse>;
]

편의를 위해서, 공통되는 내용은 위와 같이 대략만 정해놓았다.
이 때, 외부 api를 불러주는 하나의 공통함수를 만들면 아래와 같이 된다.

const callExternalUserApi = (snsType: AccountType, token: string) => {
  switch (snsType) {
    case AccountType.FACEBOOK:
      return callFaceBookApi(token);
    case AccountType.NAVER:
      return callNaverApi(token);
    case AccountType.KAKAO:
      return callKakaoApi(token);
    default:
      throw new Error('unknown sns account type');
  }
};

하지만, 이렇게 되면 리턴타입이 사용하기 불편하게 만들어진다.
예를 들어, 카카오 api만 호출한 뒤에, 필요한 것만 파싱해서 리턴하는 함수를 만들고 싶다고 해보자.

const getKaKaoTalk = async () => {
  const res = await callExternalUserApi(AccountType.KAKAO, 'token');
  console.log(res.data.kakao); // compile error!!!
};

우리는 AccountType.KAKAO를 넣어줬기에, 리턴 타입이 KakaoResponse가 되어 문제가 없을 것이라고 알고있다. 하지만 컴파일러는 그렇지 못하다.
우리는 이를 제네릭을 도입해서 해결할 수 있다.

interface ExternalApiResponseType {
  [AccountType.KAKAO]: ReturnType<typeof callKakaoApi>;
  [AccountType.NAVER]: ReturnType<typeof callNaverApi>;
  [AccountType.FACEBOOK]: ReturnType<typeof callFaceBookApi>;
}
const callExternalUserApi = <T extends AccountType>(
  snsType: T,
  token: string,
): ExternalApiResponseType[T] => {
  switch (snsType) {
    case AccountType.FACEBOOK:
      return callFaceBookApi(token) as ExternalApiResponseType[T];
    case AccountType.NAVER:
      return callNaverApi(token) as ExternalApiResponseType[T];
    case AccountType.KAKAO:
      return callKakaoApi(token) as ExternalApiResponseType[T];
    default:
      throw new Error('unknown sns account type');
  }
};

const getKaKaoTalk = async () => {
  const res = await callExternalUserApi(AccountType.KAKAO, 'token');
  console.log(res.data.kakao); // good!
};

ExternalApiResponseType은 들어온 snstype에 따라서 알맞은 리턴 타입을 만들어준다. 따라서, res의 타입을 정확하게 추정해내고, 이번에는 컴파일 에러를 건너뛸 수 있다.

딜레이와 함께 Promise.all을 수행하는 예시

배치 작업을 만들면서, 너무 많은 api 호출로 인해서 한번에 Promise.all을 돌리면 에러를 던지는 경우가 있었다.
보통 Array.prototype.map() + Promise.all을 사용했었는데, 일일히 딜레이를 넣어주는 것은 불편해서 하나의 공통 함수를 만들기로 결심하였다.

export const runWithDelay = async (
  arr: any[],
  callback: (args: any) => any,
  option: { ms?: number; batchSize?: number } = {},
): Promise<any[]> => {
  const { ms = 2000, batchSize = 1 } = option;
  let i = 0;
  let resultArr: any[] = [];
  const length = arr.length;
  while (i < length) {
    const promiseArr = [];
    const end = i + batchSize < length ? i + batchSize : length;

    for (; i < end; i++) promiseArr.push(callback(arr[i]));

    resultArr = resultArr.concat(await Promise.all(promiseArr));
    if (ms > 0) await sleep(ms);
  }

  return resultArr;
};

이건 제네릭을 쓰지 않으면 리턴 타입에 대해서 대충도 나타낼 수 없는 케이스였다.
사실 위 runWithDelay 함수는 map + Promise.all의 변형이기에, 리턴타입에 대해서는 명확히 알 수 있다.
runWithDelay 함수의 리턴값은 "callback 함수의 리턴타입의 배열"이 될 것이다.
callback 함수의 인자에 대해서도 명확히 알 수 있다. 당연히 "arr의 각 원소"가 될 것이다.
따라서 이를 그대로 제네릭으로 나타내면 된다.

export const runWithDelay = async <T, U extends (args: T) => any>(
  arr: T[],
  callback: U,
  option: { ms?: number; batchSize?: number } = {},
): Promise<Awaited<ReturnType<U>>[]> => {
  const { ms = 2000, batchSize = 1 } = option;
  let i = 0;
  let resultArr: Awaited<ReturnType<U>>[] = [];
  const length = arr.length;
  while (i < length) {
    const promiseArr: ReturnType<U>[] = [];
    const end = i + batchSize < length ? i + batchSize : length;

    for (; i < end; i++) promiseArr.push(callback(arr[i]));

    resultArr = resultArr.concat(await Promise.all(promiseArr));
    if (ms > 0) await sleep(ms);
  }

  return resultArr;
};

제네릭을 통해서, 콜백함수는 인자로 반드시 array의 원소의 타입을 받아야한다고 명시하였다.
runWithDelay는 반드시 콜백함수의 리턴값의 배열이 되어야함을 명시하였다. (단, await된 형태로)

재귀적인 제네릭

재귀적으로 타입 내부의 타입을 변형하는 제네릭

json 타입을 db에 저장해야하는 경우가 있었다.
다만, 내가 저장해야하던 객체는 안에 Date 객체가 포함되어있었다.
이 경우에는 내가 사용하는 prisma에서는 컴파일 에러를 띄웠고, 설령 any를 쓰더라도 런타임에서 에러가 발생했다.
따라서, 객체 안의 Date 객체들을 모두 .toISOString()을 사용해서 string타입으로 바꿔줘야만 했다.

import { DateFieldsToString } from './common';
import { Prisma } from '@prisma/client';

export const convertDateFieldsToString = <T>(
  objWithDate: T,
): DateFieldsToString<T> => {
  if (Array.isArray(objWithDate))
    return objWithDate.map(convertDateFieldsToString) as DateFieldsToString<T>;
  if (objWithDate instanceof Date)
    return objWithDate.toISOString() as DateFieldsToString<T>;
  if (!(objWithDate instanceof Object))
    return objWithDate as DateFieldsToString<T>;

  return Object.entries(objWithDate).reduce((acc, [key, value]) => {
    acc[key] = convertDateFieldsToString(value);
    return acc;
  }, {} as { [key: string]: Prisma.JsonValue }) as DateFieldsToString<T>;
};

이에 대한 코드적인 구현은 당연히 어렵지 않았다. 다만 문제는 타입이었다.
컴파일 에러를 피하려면 두 가지 중에 하나를 선택해야만 했다.

  1. 어차피 이미 변형이 되었으니, 대충 any로 때우고 넘어가기
  2. 그래도 any는 싫으니까 제네릭으로 잘 정의해주기

당연히 1번은 마음에 안 들었고, 2번을 선택했다.
위의 코드 구현에서 등장한 DateFieldsToString의 구현이 바로 다음 코드이다.

type Unarray<T> = T extends Array<infer U> ? U : T; // array를 unwrap 해주는 제네릭 타입

export type DateFieldsToString<T> = T extends Date
  ? string
  : T extends Array<Unarray<T>>
  ? DateFieldsToString<Unarray<T>>[]
  : {
      [P in keyof T]: T[P] extends object ? DateFieldsToString<T[P]> : T[P];
    };

Date가 등장하면, string으로 바꾼다.
그게 아니라면, key-value 쌍에 대해서, value가 object라면 재귀적으로 실행하고, 아니라면 그 타입을 유지한다.

마치며

글을 작성한 목적은 타입스크립트에서 타입을 정의하는 방법에 대한 자료들이 부족하기 때문이다.
비록 얼마 안되는 양이지만, 조금이라도 보탬이 되어 예시를 찾아보는 사람들에게 도움이 되었으면 한다.
any 혐오증이 있는 사람들이 제네릭을 통해서 더 엄격하게 타입 정의를 할 수 있었으면 좋겠다.

0개의 댓글