Frontend

TypeScript) Discriminated Union(Tagged Union)을 알아보자

mechaniccoder 2022. 12. 2. 22:54

Discriminated Union 설명

discriminated union은 type narrowing을 하기 위한 하나의 방법입니다. primitive type보다 더 복잡한 타입들을 narrow하기 위해 자주 사용됩니다. 말보단 코딩, 바로 코드로 살펴보죠.

토이 프로젝트로 sns 앱을 만들었고 피드에 올라갈 수 있는 것으로 포스팅과 광고가 있다고 해보겠습니다. Feed라는 타입을 만들고 type 필드를 통해 이를 구별하도록 했습니다. 만약 포스팅이라면 달린 댓글들이 있을 것이고 광고라면 광고주에 대한 정보가 있겠죠?따라서 이를 optional로 처리하였습니다.

interface Feed {
    type: 'post' | 'advertisement';
    advertiser?: string;
    comments?: string[];
}

피드가 광고일 경우 광고주 데이터를 가져와보죠.

const getAdvertiser = (feed: Feed) => {
    if (feed.type === 'advertisement') {
        console.log(feed.advertiser) // string | undefined
    }
}

이상한 점이 있습니다. 피드가 분명 광고인데도 feed.advertiserundefined일 수 있다는 점입니다. optional로 정의했기 때문이죠. 바로 여기서 discriminated union을 사용하면 좀 더 명시적인 타입 설계를 할 수 있습니다.

interface Advertisement {
    type: 'advertisement'
    advertiser: string;
}

interface Post {
    type: 'post';
    commnets: string[]
}

type Feed = Advertisement | Post 

const getAdvertiser = (feed: Feed) => {
    if (feed.type === 'advertisement') {
        console.log(feed.advertiser)
    }
}

Discriminated Union - React Query

요즘 비동기 처리를 위해 React Query를 많이 사용하죠? React Query에서도 discriminated union을 사용한 예시를 찾아 볼 수 있습니다.

export interface QueryObserverLoadingResult<TData = unknown, TError = unknown>
  extends QueryObserverBaseResult<TData, TError> {
  data: undefined
  error: null
  isError: false
  isLoading: true
  isLoadingError: false
  isRefetchError: false
  isSuccess: false
  status: 'loading'
}

export interface QueryObserverLoadingErrorResult<
  TData = unknown,
  TError = unknown,
> extends QueryObserverBaseResult<TData, TError> {
  data: undefined
  error: TError
  isError: true
  isLoading: false
  isLoadingError: true
  isRefetchError: false
  isSuccess: false
  status: 'error'
}

export interface QueryObserverRefetchErrorResult<
  TData = unknown,
  TError = unknown,
> extends QueryObserverBaseResult<TData, TError> {
  data: TData
  error: TError
  isError: true
  isLoading: false
  isLoadingError: false
  isRefetchError: true
  isSuccess: false
  status: 'error'
}

export interface QueryObserverSuccessResult<TData = unknown, TError = unknown>
  extends QueryObserverBaseResult<TData, TError> {
  data: TData
  error: null
  isError: false
  isLoading: false
  isLoadingError: false
  isRefetchError: false
  isSuccess: true
  status: 'success'
}

export type DefinedQueryObserverResult<TData = unknown, TError = unknown> =
  | QueryObserverRefetchErrorResult<TData, TError>
  | QueryObserverSuccessResult<TData, TError>

export type QueryObserverResult<TData = unknown, TError = unknown> =
  | DefinedQueryObserverResult<TData, TError>
  | QueryObserverLoadingErrorResult<TData, TError>
  | QueryObserverLoadingResult<TData, TError>

위와 같은 타입 설계 덕분에 컴포넌트에서 useQuery를 사용했을때 data의 타입을 좁힐 수 있습니다.

const { isLoading, isError, data }= useQuery(//...)

if (isLoading) return <div>loading...</div>
if (isError) return <div>error...</div>
return <div>{data}</div>

마치며

최근 TypeScript를 다시 공부하면서 느꼈던게 타입 설계를 얼마나 잘하냐에 따라서 어플리케이션 코드의 복잡도를 낮출 수 있는 것 같습니다. discriminated union뿐만 아니라 generic, mapped type 그리고 conditional type까지 여러 기능들을 지원하는 이유는 결국 타입도 코드와 마찬가지로 SRP, DRY 등과 같은 원칙들을 준수하는데 있어 좀 더 용이한 방법을 지원하는게 아닌가 싶습니다.
한 걸음 더 높은 수준의 코드를 작성하기 위해 앞으로 이러한 타입 설계 방법들에 대해 포스팅해보도록 하겠습니다.

References