TypeScript | Generics

typescript

프로젝트를 TypeScript로 개발하면서 axios의 get response data 타입과 RTK의 PayloadAction에서 payload의 타입을 지정해주었다.

문득 각각의 라이브러리는 어떻게 타입을 정의했기에 타입을 받아 사용할 수 있을지 궁금점이 생겼다.

// axios.get()
export interface DivisionMatchParams {
  accessId: string
  startMatchCount: number
  nickname: string
}

export const getDivisionMatchApi = (params: DivisionMatchParams) =>
  axios.get<DivisionMatchResponse>(`${BASE_URL}matches`, {
    params: {
      ...params,
    },
  })

// RTK PayloadAction
builder.addCase(
  fetchOwnerInfo.fulfilled,
  (state: OwnerState, action: PayloadAction<OwnerInfoResponse>) => {
    state.loading = false
    state.error = false
    state.ownerInfo = action.payload
  }
)

제네릭의 개념을 정리하면서 각 함수와 타입이 어떻게 정의되어 있는지 알아보았다.


제네릭이란? #

단일 타입이 아닌 다양한 타입에서 작동하는 컴포넌트를 생성하는데 사용된다. 제네릭을 통해 여러 타입의 컴포넌트나 자신만의 타입을 사용할 수 있다.


제네릭을 사용하는 이유 #

function insertAtBeginning(array: any[], value: any) {
  const newArray = [value, ...array]
  return newArray
}

const demoArray = [1, 2, 3]
const updatedArray = insertAtBeginning(demoArray, -1) // any[]

// any로 타입을 지정해주었기에 아래 문자열 메소드에서 에러가 나지 않는다
updatedArray[0].split('')

위의 예제에서 보면 updatedArray의 추론된 타입은 any[] 이다. 함수 선언시 타입을 any로 지정했기 때문에 타입스크립트가 배열에 number 타입만 들어있다는 것을 인식하지 못한다. 그 결과 문자열 메소드인 split에서 에러가 발생하지 않는다. 함수 호출한 다음 타입스크립트로부터 어떤 지원도 받을 수 없다.

function insertAtBeginning(array: number[], value: number) {
  const newArray = [value, ...array]
  return newArray
}

const demoArray = [1, 2, 3]
const updatedArray = insertAtBeginning(demoArray, -1) // number[]

위와 같이 선언 시 타입을 지정해 줄 수 있지만, 지정 타입 이외의 다른 타입에서는 사용이 불가능하다. 유틸리티 함수와 같이 여러 타입이 들어 올 가능성이 있다면 그 함수는 재사용하기 어렵다.


타입의 안정성과 유연성을 주기 위해 제네릭을 사용한다.!


제네릭 사용 방법 #

function insertAtBeginning<T>(array: T[], value: T) {
  const newArray = [value, ...array]
  return newArray
}

const demoArray = [1, 2, 3]
const updatedArray = insertAtBeginning(demoArray, -1) // number[]
// 또는 아래와 같이 명시할 수 있다.
const updatedArray = insertAtBeginning<number>(demoArray, -1) // number[]

// number 배열이라는 것을 인식해 에러 발생
updatedArray[0].split('')

const stringArray = insertAtBeginning(['a', 'b'], 'c') // string[]

함수 안에서만 사용할 수 있는 제네릭 타입 정의할 수 있다. 보통 Type의 T를 사용 타입 변수를 추가한다. 정의하면 타입을 함수와 매개변수 목록에서 사용할 수 있다. 함수를 호출하면 이 변수는 유저가 준 타입을 캡쳐하고 이 값을 이제 타입스크립트는 알 수 있다.

컴파일러는 number[]로 추론해 Type의 값을 자동으로 정하게 된다. 제네릭 타입을 사용해 타입스크립트에게 any 타입이 아니라는 것을 알려준다. 어떤 타입이든 사용할 수 있지만, 특정 타입을 사용해 함수를 실행하면 해당 타입으로 고정되어 동작하게 한다.


클래스나 인터페이스에서도 다양한 타입으로 재사용할 수 있다.

// interface
interface Demo<T> {
  name: string
  options: T
}

const demo1: Demo<string> = {
  name: 'demo1',
  options: 'red',
}

const demo2: Demo<{ color: string; age: number }> = {
  name: 'demo2',
  options: {
    color: 'blue',
    age: 10,
  },
}


제네릭 제약조건 (extends) #

function showName<T>(data: T): string {
  return data.name
}

위의 예제에서 data의 .name 프로퍼티에 접근하려 했지만, 모든 타입에서 .name을 가지고 있는지 알 수 없다. .name 프로퍼티가 있는 모든 타입들에서 작동하는 것으로 제한하고 싶을 때 아래와 같이 Type에 제약조건을 줄 수 있다.

function showName<T extends { name: string }>(data: T): string {
  return data.name
}

// 혹은 제약조건을 인터페이스로 정의

interface NameIn {
  name: string
}

function showName<T extends NameIn>(data: T): string {
  return data.name
}

제네릭 함수는 이제 제한되어있어 필요한 프로퍼티들이 있는 타입의 값을 전달해야한다.

interface User {
  name: string
  age: number
}

interface Car {
  name: boolean
}

interface Book {
  price: number
}

const user: User = { name: 'abc', age: 10 }
const car: Car = { name: false }
const book: Book = { price: 1000 }

function showName<T extends { name: string }>(data: T): string {
  return data.name
}

showName(user)
showName(car) // car의 name 타입이 boolean이기 때문에 타입 에러 발생
showName(book) // name이 없기 때문에 에러 발생

다른 타입의 매개변수로 제한된 타입 매개변수를 선언할 수 있다.

// 공식문서 예제
// obj에 존재하지 않는 프로퍼티를 가져오지 않도록 하기 위해 제약조건을 걸었다.
function getProperty<Type, Key extends keyof Type>(obj: Type, key: Key) {
  return obj[key]
}

let x = { a: 1, b: 2, c: 3, d: 4 }

getProperty(x, 'a')
getProperty(x, 'm')


Axios.get() #

아래와 같이 타입 변수에 response에 대한 타입을 설정해주고 있다.

export interface DivisionMatchParams {
  accessId: string
  startMatchCount: number
  nickname: string
}

export const getDivisionMatchApi = (params: DivisionMatchParams) =>
  axios.get<DivisionMatchResponse>(`${BASE_URL}matches`, {
    params: {
      ...params,
    },
  })


https://github.com/axios/axios/blob/v1.x/index.d.ts

Axios.get<T = any, R = AxiosResponse<T, any>, D = any>(url: string, config?: AxiosRequestConfig<D> | undefined): Promise<R>

export interface AxiosResponse<T = any, D = any>  {
  data: T;
  status: number;
  statusText: string;
  headers: RawAxiosResponseHeaders | AxiosResponseHeaders;
  config: AxiosRequestConfig<D>;
  request?: any;
}

T에 DivisionMatchResponse 타입을 주면서 response의 data 타입을 설정한다.

export interface AxiosRequestConfig<D = any> {
  url?: string
  method?: Method | string
  baseURL?: string
  transformRequest?: AxiosRequestTransformer | AxiosRequestTransformer[]
  transformResponse?: AxiosResponseTransformer | AxiosResponseTransformer[]
  headers?: RawAxiosRequestHeaders
  params?: any
  paramsSerializer?: ParamsSerializerOptions
  data?: D
  timeout?: number
  timeoutErrorMessage?: string
  withCredentials?: boolean
  adapter?: AxiosAdapter
  auth?: AxiosBasicCredentials
  responseType?: ResponseType
  responseEncoding?: responseEncoding | string
  xsrfCookieName?: string
  xsrfHeaderName?: string
  onUploadProgress?: (progressEvent: AxiosProgressEvent) => void
  onDownloadProgress?: (progressEvent: AxiosProgressEvent) => void
  maxContentLength?: number
  validateStatus?: ((status: number) => boolean) | null
  maxBodyLength?: number
  maxRedirects?: number
  maxRate?: number | [MaxUploadRate, MaxDownloadRate]
  beforeRedirect?: (
    options: Record<string, any>,
    responseDetails: { headers: Record<string, string> }
  ) => void
  socketPath?: string | null
  httpAgent?: any
  httpsAgent?: any
  proxy?: AxiosProxyConfig | false
  cancelToken?: CancelToken
  decompress?: boolean
  transitional?: TransitionalOptions
  signal?: GenericAbortSignal
  insecureHTTPParser?: boolean
  env?: {
    FormData?: new (...args: any[]) => object
  }
  formSerializer?: FormSerializerOptions
}

config 타입 정의도 보면 params의 타입은 any로 지정된 것을 볼 수 있다.


PayloadAction #

createAsyncThunk를 이용해 비동기 통신으로 데이터를 받아오고 받아오는 데이터의 타입을 payload의 타입으로 지정했다.

builder.addCase(
  fetchOwnerInfo.fulfilled,
  (state: OwnerState, action: PayloadAction<OwnerInfoResponse>) => {
    state.loading = false
    state.error = false
    state.ownerInfo = action.payload
  }
)


https://github.com/reduxjs/redux-toolkit/blob/master/packages/toolkit/src/createAction.ts

/**
 * An action with a string type and an associated payload. This is the
 * type of action returned by `createAction()` action creators.
 *
 * @template P The type of the action's payload.
 * @template T the type used for the action type.
 * @template M The type of the action's meta (optional)
 * @template E The type of the action's error (optional)
 *
 * @public
 */
export type PayloadAction<
  P = void,
  T extends string = string,
  M = never,
  E = never
> = {
  payload: P
  type: T
} & ([M] extends [never]
  ? {}
  : {
      meta: M
    }) &
  ([E] extends [never]
    ? {}
    : {
        error: E
      })

PayloadAction은 payload 프로퍼티의 타입을 지정할 수 있게 해주는 제네릭이다.

meta와 error에 대해 아직 사용해보지 않아 위의 payload와 type만 보면, P에 OwnerInfoResponse 타입을 주면서 payload의 타입을 정한다.


타입스크립트 컴파일러에서 올바르게 타입을 처리할 수 있게 제네릭을 사용한다.


Reference #

https://www.typescriptlang.org/docs/handbook/2/generics.html

https://www.udemy.com/course/best-react/

https://www.youtube.com/watch?v=pReXmUBjU3E

https://github.com/axios/axios

https://redux-toolkit.js.org/usage/usage-guide