[Typescript] 고급 타입 - 제네릭 (Generic)

mainsain·2024년 2월 12일
0

Typescript

목록 보기
5/8
post-thumbnail

제네릭 (Generic)

내부적으로 사용할 타입을 정해두지 않고 타입 변수를 사용해서 해당 위치를 비우고, 사용할 때 외부에서 타입 변수 자리에 타입을 지정하여 사용하는 방식

왜 필요할까

function getSize(arr : number[]) : number {
    return arr.length;
}

const arr1 = [1, 2, 3];
getSize(arr1); // 3

const arr2 = ["a", "b", "c"];
getSize(arr2); // error

이러한 상황에서, getSize의 타입이 불일치해 에러가 발생했다.

function getSize(arr : number[] | string[]) : number {
    return arr.length;
}

이를 임시방편으로 해결하기 위해, 유니온 선언으로 확장했지만, boolean, Object등 더 많아지면 굉장히 지저분한 코드가 만들어진다.

function getSize<T>(arr: T[]): number {
  return arr.length;
}

const arr1 = [1, 2, 3];
getSize<number>(arr1); // 3

이렇게 타입 변수로 와 같이 정의하고, 사용시엔 원하는 타입을 넣어주면 된다.

변수명으로 T(Type), E(Element), K(Key), V(Value) 등 한 글자로 된 이름을 많이 사용한다.

any랑 다를게 없잖아요?

any : 타입 검사를 하지 않고 모든 타입을 허용한다.
Generic : 생성 시점에 원하는 타입으로 특정할 수 있다. 따라서, 요소가 전부 동일한 타입임을 보장할 수 있다.

any vs unknown

제네릭 사용법

함수의 제네릭

function ReadOnlyRepository<T>(target: ObjectType<T> | EntitySchema<T> | string):
Repository<T> {
  return getConnection(“ro”).getRepository(target);
}

위 예시는 데이터베이스의 읽기 전용 연결을 통해 특정 타입의 레포 객체를 가져오는 데 사용한다.

이처럼 T자리에 넣는 타입에 따라 ReadOnlyRepository가 적절하게 사용될 수 있다.

호출 시그니처의 제네릭

쉬운 예시

function sum(a: number, b: number): number {
  return a + b
} // 이거를

type myFunction = (a: number, b: number) => number; // 이런식으로 만드는게
const thatsMine: myFunction = (a, b) => a + b; // 호출 시그니처

호출 시그니처(call signature) : 타입스크립트의 함수 타입 문법으로, 함수의 매개변수와 반환 타입을 미리 선언하는 것

배민선물하기팀의 호출 시그니처 제네릭 활용 예시

interface useSelectPaginationProps<T> {
    categoryAtom: RecoilState<number>;
    filterAtom: RecoilState<string[]>; sortAtom:
    RecoilState<SortType>;
    fetcherFunc: (props: CommonListRequest) = > Promise<DefaultResponse<ContentListRes
    ponse<T>>>;
  }

를 useSelectPaginationProps의 타입 별칭으로 한정했기에 제네릭 타입을 구체 타입으로 한정한다.
(만일 User Type으로 한정되었다면, ContentListResponse<T>의 도 User type이기에 구체 타입으로 한정했다고 표현한다.)

배민커머스 웹프론트엔드 개발팀 활용 예시

export type UseRequesterHookType = <RequestData = void, ResponseData = void>(
  baseURL?: string | Headers,
  defaultHeader?: Headers
) => [RequestStatus, Requester<RequestData, ResponseData>];

이 코드의 경우에도 두개의 제네릭 타입을 구체 타입으로 한정했다.

제네릭 클래스

쉬운 예시

class GenericNumber<T> {
    zeroValue: T;
    add: (x: T, y: T) => T;
}

let myGenericNumber = new GenericNumber<number>();
myGenericNumber.zeroValue = 0;
myGenericNumber.add = function(x, y) { return x + y; };

외부에서 입력된 타입을, 클래스 내부에 적용

class LocalDB<T> {
    // ...
    async put(table: string, row: T): Promise<T> {
      return new Promise<T>((resolved, rejected) = > { /* T 타입의 데이터를 DB에 저장 */ });
    }
  
    async get(table:string, key: any): Promise<T> {
      return new Promise<T>((resolved, rejected) = > { /* T 타입의 데이터를 DB에서 가져옴 */ });
    }
  
    async getTable(table: string): Promise<T[]> {
      return new Promise<T[]>((resolved, rejected) = > { /* T[] 타입의 데이터를 DB에서 가져옴*/ });
    }
  }
  
  export default class IndexedDB implements ICacheStore {
    private _DB?: LocalDB<{ key: string; value: Promise<Record<string, unknown>>;
    cacheTTL: number }>;
  
    private DB() {
      if (!this._DB) {
        this._DB = new LocalDB(“localCache”, { ver: 6, tables: [{ name: TABLE_NAME, keyPath: “key” }] });
      }
      return this._DB;
    }
    // ...
  }

제한된 제네릭

function loggingIdentity<T>(arg: T): T {
    console.log(arg.length);  // error: T에는 .length가 없다.
    return arg;
}

컴파일러는 모든 타입에서 .length 프로퍼티를 갖는지 증명할 수 없기에 에러를 띄운다.

따라서 특정 타입을 상속(extends)을 통해 제약사항을 명시하는 방법을 활용한다.

interface Lengthwise {
    length: number;
}

function loggingIdentity<T extends Lengthwise>(arg: T): T {
    console.log(arg.length);  // 이제 .length 프로퍼티가 있는 것을 알기 때문에 더 이상 오류가 발생하지 않습니다.
    return arg;
}

loggingIdentity(3);  // 오류, number는 .length 프로퍼티가 없습니다.
loggingIdentity({length: 10, value: 3});

근데 이렇게 인터페이스 뿐만 아니라 기본 타입, 클래스도 사용할 수 있으며 유니온 타입을 상속해서 선언할 수도 있다.

function useSelectPagination<
  T extends CardListContent | CommonProductResponse
>({
  filterAtom,
  sortAtom,
  fetcherFunc,
}: useSelectPaginationProps<T>): {
  intersectionRef: RefObject<HTMLDivElement>;
  data: T[];
  categoryId: number;
  isLoading: boolean;
  isEmpty: boolean;
} {
  // ...
}

// 사용하는 쪽 코드
const { intersectionRef, data, isLoading, isEmpty } =
  useSelectPagination<CardListContent>({
    categoryAtom: replyCardCategoryIdAtom,
    filterAtom: replyCardFilterAtom,
    sortAtom: replyCardSortAtom,
    fetcherFunc: fetchReplyCardListByThemeGroup,
  });

확장된 제네릭

제네릭 타입은 여러 타입을 상속받을 수 있으며 타입 매개변수를 여러 개 둘 수도 있다.

<Key extends string> 이렇게 제약해버리면 제네릭의 유연성을 잃어버리기에, 유니온 타입을 상속해서 선언해버리자.

<Key extends string | number> 여러 타입을 받을 수 있게 되었지만, 타입 매개변수가 여러개일땐?

매개변수를 하나 더 추가하여 선언하면 된다.

export class APIResponse<Ok, Err = string> {
  private readonly data: Ok | Err | null;
  private readonly status: ResponseStatus;
  private readonly statusCode: number | null;

  constructor(
    data: Ok | Err | null,
    statusCode: number | null,
    status: ResponseStatus
  ) {
    this.data = data;
    this.status = status;
    this.statusCode = statusCode;
  }

  public static Success<T, E = string>(data: T): APIResponse<T, E> {
    return new this<T, E>(data, 200, ResponseStatus.SUCCESS);
  }

  public static Error<T, E = unknown>(init: AxiosError): APIResponse<T, E> {
    if (!init.response) {
      return new this<T, E>(null, null, ResponseStatus.CLIENT_ERROR);
    }

    if (!init.response.data?.result) {
      return new this<T, E>(
        null,
        init.response.status,
        ResponseStatus.SERVER_ERROR
      );
    }

    return new this<T, E>(
      init.response.data.result,
      init.response.status,
      ResponseStatus.FAILURE
    );
  }

  // ...
}

// 사용하는 쪽 코드
const fetchShopStatus = async (): Promise<
  APIResponse<IShopResponse | null>
> => {
  // ...

  return (await API.get<IShopResponse | null>("/v1/main/shop", config)).map(
    (it) => it.result
  );
};

APIResponse<Ok, Err = string> 두개의 타입 매개변수를 지정해줘야 하고, Err타입을 지정하지 않으면 자동으로 string타입이 지정된다는 의미이다.

제네릭 예시

돌고돌아 제네릭의 장점은, 다양한 타입을 받게 함으로써 코드를 효율적으로 재사용할 수 있다는 것이다.

현업에서 제네릭이 가장 많이 활용할때는, API 응답 값의 타입을 지정할 때이다.

export interface MobileApiResponse<Data> {
  data: Data;
  statusCode: string;
  statusMessage?: string;
}

API응답 값에 따라 달라지는 data를 제네릭타입 Data로 선언하고

export const fetchPriceInfo = (): Promise<MobileApiResponse<PriceInfo>> => {
  const priceUrl = "https: ~~~"; // url 주소

  return request({
    method: "GET",
    url: priceUrl,
  });
};

export const fetchOrderInfo = (): Promise<MobileApiResponse<Order>> => {
  const orderUrl = "https: ~~~"; // url 주소

  return request({
    method: "GET",
    url: orderUrl,
  });
};

호출시에 상황에 따라 다르게 적용하여 코드를 효율적으로 재사용하고 있다.

제네릭을 굳이 사용하지 않아도 되는 타입

생각하면서 코드를 짜라. 아래는 불필요한 제네릭의 예시이다.

type GType<T> = T;
type RequirementType = "USE" | "UN_USE" | "NON_SELECT";
interface Order {
  getRequirement(): GType<RequirementType>;
}

// 위 코드는 그냥 제네릭 안쓰고 아래처럼 작성하는게 낫다

type RequirementType = "USE" | "UN_USE" | "NON_SELECT";
interface Order {
  getRequirement(): RequirementType;
}

만약 내가 작성한 코드를 다른 개발자가 쉽게 이해하지 못하고있다면, 제네릭 오남용하고 있는건 아닌지 검토해보길 바란다.

profile
새로운 자극을 주세요.

0개의 댓글