Posts
typescript
조건부 제네릭 타입으로 깔끔하게 추론하기

발단

사내 디자인 시스템과 관련된 UI util함수나 hook을 만들어보고 있다.

그 중 useBreakpoint라는 거의 모든 앱에서 사용하고 있는 custom hook을 구현하 공통 라이브러리에 넣어놓고 사용하면 좋을 것 같아서 다른 hook들과 함께 넣기로 했다.

useBreakpoint

mui에서 제공하는 useMediaQuery 를 한번 더 감싸서 좀 더 빠르게 theme type을 적용하여 사용할 수 있도록 하는, hook이다. 구현 코드가 아주 간단하기 때문에 처음엔 그냥 코드만 옮겨올 생각이었지만 코드를 옮겨오던 중 새로운 사실을 알았다.

평소에는 down query만 주로 사용하기 때문에 몰랐는데, 기본적으로 사용할 수 있는 다음 다섯 가지 query중 "between" 은 사용하지 못하도록 되어있었다.

그 이유는 "between" query를 사용하는 경우 추가적으로 endBreakpoint 파라미터도 받아야하기 때문에 , 구현이 까다로워지기 때문이 아니었을까? 괜한 오기(?)가 생겨서 이 between query까지 사용할 수 있는 useBreakpoint를 만들어보기로 했다.

참고로, useMediaQuery에서 사용가능한 query는 다음과 같이 5가지이다.

  • ‘down’, ‘up’, ‘between’, only', ‘not’

다룰 내용

  • ‘between’ query 인 경우 endBreakpoint도 받아야함.
  • 즉, 특정 파라미터로 넘어오는 인자에 따라 추가적인 파라미터를 받을지 말지의 여부가 결정된다.
  • 이를 어떻게 타입스크립트로 잘 타이핑 할 수 있을지 고민
  • 여러가지 방법이 있을 것 같았고, 이상하게도 어떤 방식은 생각처럼 잘 타입스크립트가 추론을 못했고, 결국 조건부 제네릭 타입으로 해결

일단 useBreakpoint 에 필요한 프롭은 다음과 같다.

Parameter

  • breakpoint : 'xs' | 'sm' | 'md' | 'lg' | 'xl'

  • query : 'up' | 'down' | 'between' | 'only' | 'not'

    import type { Breakpoint } from '@mui/materail';
     
    type Query = Extract<
      keyof Breakpoints,
      'up' | 'down' | 'between' | 'only' | 'not'
    >;
  • endBreakpoint? : 'xs' | 'sm' | 'md' | 'lg' | 'xl'

  • options? : UseMediaQueryOptions

시도한 방법들

1. Union Type

다음과 같이 두개의 타입을 선언했고, 이를 union으로 합쳐보았다.

 
export interface UseBreakpointBaseProps {
  /**
   * The breakpoint to be used.
   * @enum 'xs' | 'sm' | 'md' | 'lg' | 'xl'
   */
  breakpoint: Breakpoint;
  /**
   * Media query options.
   */
  options?: UseMediaQueryOptions;
}
 
interface UseBreakpointBetweenQueryProps extends UseBreakpointBaseProps {
  /**
   * The query type to be used.
   * @enum 'between'
   */
  query: Extract<Query, 'between'>;
  /**
   * The breakpoint to end the query when using 'between' query.
   * @enum 'xs' | 'sm' | 'md' | 'lg' | 'xl'
   */
  endBreakpoint: Breakpoint;
}
 
interface UseBreakpointPropsOtherQueryProps extends UseBreakpointBaseProps {
  /**
   * The query type to be used.
   * @enum 'up' | 'down' | 'only' | 'not'
   */
  query: Exclude<Query, 'between'>;
  /**
   * The breakpoint to end the query when using 'between' query.
   */
  endBreakpoint?: never;
}
 
export type UseBreakpointProps =
  | UseBreakpointBetweenQueryProps
  | UseBreakpointPropsOtherQueryProps;
 
//
//
//
 
export const useBreakpoint = ({
  breakpoint,
  query,
  endBreakpoint,
  options,
}: UseBreakpointProps) => {

이렇게 between query가 아닐 땐 endBreakpoint를 optional로 never type으로 제한해보았다.

아예 endBreakpoint 속성을 UseBreakpointPropsOtherQueryProps 로부터 없애면, union으로 합치면서 endBreakpointUseBreakpointProps 에서 없어지게 되기 때문에 never 타입을 사용해보았다.

이렇게 사용해보니, between query를 사용하지 않았는데 endBreakpoint 속성을 넘기려할 때, 의도한 대로 타입에러가 발생했다.

union type error

음.. 하지만 뭔가 부족했다.

애초에 자동 완성에서 endBreakpoint가 뜨지 않았으면 좋겠는데, 작성하면서 endBreakpoint가 보이면 사용자가 헷갈리지 않을까? 나는 타입스크립트 자동완성 없이는 못 사는 사람인데..

union type error

한편으론 이럴 때 union 타입을 쓰는 것이 적절한 방식일까? 하는 의문도 들기 시작했다. 아예 틀렸다곤 할 수 없지만, 더 좋은 방법이 있지 않을까?

2. Function Overloading

자주 사용하진 않지만, 사용하는 라이브러리들의 d.ts파일들을 보다보면 자주 발견하곤 하는 함수 오버로딩. 예에전 사내 타입스크립트 스터디에서 다루었지만 직접 실무에서 필요해서 써본 적이 별로 없어서 한 번 시도해보기로 했다.

함수 오버로드란? 예전 함수 오버로딩에 대해 정리한 글을 참조하자면,

동일한 함수 이름을 가지지만 매개변수의 개수와 타입, 반환 타입 등이 다른 여러 개의 시그니처를 가지는 것. 다양한 매개변수 조합에 대해 다른 동작을 수행하고 다른 타입을 반환할 수 있다.

  • 어떻게 호출 시그니쳐를 여러개 가질 수 있는가?

⇒ 자바스크립트는 동적 타입 언어. 따라서 함수에 넘겨지는 인수 타입에 따라 반환타입이 달라질 수 있다.입력 타입에 따라 달라지는 함수의 출력 타입을 표현할 수 있도록 ‘함수 오버로드’라는 고급 기능으로 지원한다.

(자세한 내용은 여기 잘 정리되어 있다!)

Typescript week2: Function (1)

구현 코드

export function useBreakpoint(
  breakpoint: Breakpoint,
  query: 'between',
  endBreakpoint: Breakpoint, // 'between' query인 경우 endBreakpoint를 받는다.
  options?: UseMediaQueryOptions
): boolean;
 
export function useBreakpoint(
  breakpoint: Breakpoint,
  query: Exclude<Query, 'between'>, 
  // between이 아닌 query타입인 경우, endBreakpoint 파라미터가 없다.
  options?: UseMediaQueryOptions
): boolean;
 
//
//
//
 
export function useBreakpoint(
  breakpoint: Breakpoint,
  query: Query,
  endBreakpointOrOptions?: Breakpoint | UseMediaQueryOptions,
  options?: UseMediaQueryOptions
) {
  const theme = useTheme();
 
  const queryString =
    query === 'between'
      ? theme.breakpoints[query](
          breakpoint,
          **endBreakpointOrOptions as Breakpoint**
        )
      : theme.breakpoints[query](breakpoint);
  const matches = useMediaQuery(queryString, options);
 
  return matches;
}
 

세번째 parameter가 options이 올지, breakpoint가 올지 모르기 때문에 구현쪽에선 네이밍을 endBreakpointOrOptions 와 같이 할 수 밖에 없었다. (”타입스크립트 프로그래밍”이라는 책에서도 이렇게 구현하고 있다.)

  • 이 부분이 상당히 마음에 들지 않는다. 무엇인지, 경우에 따라 전혀 다른 타입일 수 있는 변수라니 numberOrString이라는 변수를 작성한 사람이 있다면 바로 찾아가고 싶을텐데…

중요한 사용 측면. 아쉽게도 타입스크립트가 추론을 잘 하지 못했다. ’sm’, ‘up’ 파라미터를 넘기고나면 아래와 같이 options 가 올 차례라고 알려주기도 했지만,

function overloading success

직전에 between query를 사용했거나, 세번째 인자에 따옴표를 입력하면 다음과 같이 endBreakpoint 타입이 표시되었다.

function overloading failed

between query를 사용할 때도 마찬가지였다. 이전에 다른 query를 사용했거나, 객체 리터럴을 입력하기 위해 중괄호를 열면 options 파라미터가 올 차례라고 판단하는 것 같았다.

between-query

이 이유에 대해서는 정확히 알 수 없지만, 함수 오버로드를 사용한다면 넘긴 인자에 따라 동적으로 다음 인자의 타입을 정확히 추론하는 데에 한계가 있다고 판단되었다.

그래서 역시 이럴 땐 제네릭인가.. 하며 다시 생각해보았다.

내가 지금 하고 싶은 건 ‘between’일 땐 breakpoint type, 그외의 쿼리일 땐 options type. 딱 조건적이구나. 조건부로 동적으로 타입을 할당하고 싶다면?

3. Conditional Generic type

사실 처음부터 생각하지 않은 것은 아니지만, 제네릭 없이도 어디까지 가능한지 알고 싶었다.. 😅

위의 한계들을 마주하고. 그래 역시 제네릭이지!하며 다음과 같이 작성했다.

구현코드

interface BetweenQueryParams {
  breakpoint: Breakpoint;
  query: 'between';
  endBreakpoint: Breakpoint;
  options?: UseMediaQueryOptions;
}
 
**type UseBreakpointParams<T extends Query> = T extends 'between'
  ? BetweenQueryParams
  : { breakpoint: Breakpoint; query: T; options?: UseMediaQueryOptions };**
 
//
//
//
 
export function useBreakpoint<T extends Query>(
  params: UseBreakpointParams<T>
): boolean {
  const theme = useTheme();
  const { breakpoint, query, options } = params;
 
  const queryString =
    query === 'between'
      ? theme.breakpoints[query](breakpoint, params.endBreakpoint)
      : theme.breakpoints[query](breakpoint);
  const matches = useMediaQuery(queryString, options);
 
  return matches;
}
 

내부 구현 코드도 너무 깔끔하지 않은가…? 설명을 붙이자면, UseBreakpointParams T라는 Query 타입이 올수 있는 제네릭 파라미터를 갖는다. 이 T의 타입이 between 인 경우, UseBreakpointParams BetweenQueryParams 으로 할당되며, 그렇지 않은 경우는 query 속성이 T 타입으로 추론된다.

breakpointOrOptions 과 같은 변수명 내다버릴 수 있다.

물론 이렇게 OtherQueryParams를 선언해도된다.

 
interface OtherQueryParams<T> {
  breakpoint: Breakpoint;
  query: T;
  options?: UseMediaQueryOptions;
}
 
type UseBreakpointParams<T extends Query> = T extends 'between'
  ? BetweenQueryParams
  : OtherQueryParams<T>;
 

그래서 결과는?

conditional generic type result1 conditional generic type result2

이렇게 넘긴 query 속성에 따라 올 수 있는 프롭을 아주 잘 추론해준다!

TL;DR

  • useBreakpoint 함수는 query 타입에 따라 다른 파라미터를 받아야 하는 문제를 해결하고자 함⁠
  • Union Type, Function Overloading, Conditional Generic Type 세 가지 방법을 시도함⁠⁠

시도한 방법들

  • Union Type: 의도한 대로 타입 에러는 발생했지만, 자동 완성 시 불필요한 속성이 표시되는 문제가 있었음⁠1 (opens in a new tab)
  • Function Overloading: 구현은 가능했지만, 타입스크립트가 인자에 따른 다음 인자의 타입을 정확히 추론하지 못하는 한계가 있었음⁠⁠
  • Conditional Generic Type: 가장 효과적인 방법으로, query 속성에 따라 올 수 있는 프롭을 정확히 추론함⁠⁠

결론

Conditional Generic Type을 사용하여 깔끔하고 정확한 타입 추론이 가능한 해결책을 찾음⁠

이 방법을 통해 불필요한 변수명 사용을 피하고 타입 안정성을 높일 수 있었음⁠⁠