Typed Modal system

타입 검증을 보장하는 모달 시스템을 위한 구조 설계

모달을 어떻게 열 것인가

모달 시스템의 설계에서 가장 중요하면서 가장 까다로웠던 부분은, 모달을 어떻게 열 것인가- 였다. 이 부분에서 중요하게 유지해야 할 점으로 염두에 두었던 점이 두 가지가 있다.

첫 번째로는 모달을 열 때, 언제 어디서든 어떤 모달이든 띄울 수 있어야 한다는 점이었다. 그렇기 때문에 <SomeModal visible={true} /> 방식으로 특정 페이지에 특정 모달을 제한하는 방식은 옵션이 아니었다. 대신 이 부분을 적용하기 위해 (그리고 거의 항상 같이 사용하는, redux와 같은) 전역 스토어를 활용한다. 때문에 모달을 연다는 것은, 타입과 속성을 한번에 담아 액션으로 dispatch(openModal({ type, props })) 하는 것과 동일한 의미가 된다. 사실, 이 문제는 이미 이 글을 쓰던 시점에 이미 원하던 모습에 90% 가까워졌다고 생각한다. 지금의 구조와는 디테일한 부분에서 차이가 조금 있다. redux를 사용하지 않는다면? 컨텍스트를 사용해서 모달을 구현해보자(...Writing)

두 번째는 모달 타입에 따른 속성값 일치 여부가 타입시스템으로 검증 가능할 수 있는 것을 목표로 삼았다. 타입스크립트와 모달 시스템의 조화를 이루는 것. 하지만 첫 번째 목표를 위해 이미 redux를 사용중이었기 때문에, 이 문제는 openModal()의 타입을 어떻게 정의할 것인가?와 같은 질문이 된다. 일단, 차근차근 하나씩 생각해보자.

// this action creator will return
openModal({ type: 'Alert', props: { message: 'Hello' } })

// this action
const action = { type: '@modal/OPEN_MODAL', payload: { type: 'Alert', props: { message: 'Hello' } } }

// and trigger rendering this Component
return <Alert message="Hello" />

<Alert /> 이라는 컴포넌트가 정의되어 있을 것이다. 여기에서 이 컴포넌트의 이름과 props 정도는 알아낼 수 있다. Alert의 props는 interface AlertProps { message: string }를 받는다고 하자. 하지만 <Confirm /> 이라는 컴포넌트도 존재하며, 이 컴포넌트의 props는 interface ConfirmProps { onConfirm(): void } 때, 이 두 케이스를 모두 커버하는 하나의 액션을 만들어낼 수 있을까?

타입시스템으로 검증 가능한 액션 만들어내기

일단 첫 구상은 이런 것에서 시작했다.

import Alert from "./Alert"
import Confirm from "./Confirm"

// make map of components
const modals = { Alert, Confirm }

// extract some useful types
type Modals = typeof modals
type ModalType = keyof Modals
type ModalProps<T extends ModalType> = React.ComponentProps<Modals[T]>

type Test1 = ModalProps<'Alert'> // == { message: string }
type Test2 = ModalProps<'Confirm'> // == { onConfirm(): void }

그러니까 'Alert'만 있으면 { message: string } 라는 타입을 뽑아낼 수가 있다. 이제 payload의 타입을 이렇게 표현할 수 있다.

type OpenModalPayload<T extends ModalType> = { type: T; props: ModalProps<T> }

아주 멋지다! 이제 이걸로 액션을 만들어보자. 타입스크립트와 리덕스를 사용하고 있자니, 웬만하면 둘 중 하나의 라이브러리에 기준을 맞추게 되는 것 같다. @reduxjs/toolkit 혹은 typesafe-actions. 요즘엔 리덕스툴킷이 "공식"의 이름을 업고 승기를 이미 잡아가는 듯 하다. 한번 @reduxjs/toolkit의 createAction에 이 Payload 타입을 적용해보자!

import { createAction } from "@reduxjs/toolkit"
const openModal = createAction<OpenModalPayload<ModalType>>("@modal/OPEN_MODAL")
openModal({ type: "Alert", props: { message: "test" } }) // No Error, OK
openModal({ type: "Alert", props: { onConfirm: () => {} } }) // Error, OK

깔끔하다! 여기까지 진행된 코드는 여기에서 모아서 볼 수 있다.

난관 봉착

난관은 이 타이핑을 더 개선시키려다가 마주했다. 그러니까, 이런 걸 하고 싶었다.

interface BasicModalProps {
  close(): void
}

interface AlertProps extends BasicModalProps {
  message: string
}
interface ConfirmProps extends BasicModalProps {
  onConfirm(): void
}

그리고 close는 모든 모달에게 공통적으로 주입해주고 싶었기 때문에, ModalContainer가 렌더링 할 때 넣어준다. 이런 방식으로.

// ModalContainer.tsx
const ModalContainer = () => {
  const openedModals: OpenModalPayload<ModalType>[] = [{ type: "Alert", props: { message: "test" } }] // got from store

  return (
    <>
      {openedModals.map(({ type, props }) => {
        const Component = modals[type]
        function closeSelf() {
          /* dispatch close action */
        }
        return <Component {...(props as any)} close={closeSelf} />
      })}
    </>
  )
}

그런데, 이제 이런 문제가 생겼다.

openModal({ type: "Alert", props: { message: "test" } })
//                         ^^^^^ Property 'close' is missing in type ...

아 그렇지. openModal의 payload 에서까지 close 함수를 알 필요는 없지.

type ModalOwnProps<T extends ModalType> = Omit<ModalProps<T>, keyof BasicModalProps>
type OpenModalPayload<T extends ModalType> = { type: T; props: ModalOwnProps<T> }

아니 그랬더니 이번엔 이런 문제가 생기는게 아닌가

openModal({ type: "Alert", props: { message: "test" } })
//                                  ^^^^^^^^^^^^^^^
// Type '{ message: string; }' is not assignable to type 'Omit<(AlertProps & { children?: ReactNode; }) | (ConfirmProps & { children?: ReactNode; }), "close">'.
//  Object literal may only specify known properties, and 'message' does not exist in type 'Omit<(AlertProps & { children?: ReactNode; }) | (ConfirmProps & { children?: ReactNode; }), "close">'.(2322)

그러니까... 이 메시지를 아주 잘 풀어 해석하자면... (이 뜻을 깨닫는데 1년 가까이 걸렸던 것 같다) 원래 AlertProps | ConfirmProps 라는 union 타입이던 ModalOwnProps가 Omit을 통과하면서, 다시 추론이 되었는데 Omit<(AlertProps | ConfirmProps), "close">를 어찌 저찌 뽑아보니, Object literal, 즉 {}로 계산되었다는 말이다.

이게 무슨 일인지 간단한 버전으로 테스트를 해보자.

type Test = Omit<{ a: number } | { b: number }, "c"> // === {}

대체 무슨 일이 일어난건지, Omit의 정의를 확인한 뒤 이걸 풀어 써 보자.

type Omit<T, K extends keyof any> = Pick<T, Exclude<keyof T, K>> // 라는 정의를 가지고 있다.

// Test를 풀어써보자면
type OriginalObject = { a: number } | { b: number }
type Test1 = Pick<OriginalObject, Exclude<keyof OriginalObject, "c">>

// 그런데 여기에서 union 타입의 키를 뽑아오려고 하면, 공통된 키만 뽑게 된다.
// https://github.com/Microsoft/TypeScript/issues/12948 여기를 참고!
type Keys = keyof OriginalObject // === never

// 그렇기에 당연히
type KeysWithoutC = Exclude<never, "c"> // === never

// 그래서
type Test2 = Pick<OriginalObject, never> // === {}

대책

이걸 조금 더 고민해보다가: 이 난관을 해결할 방법은, Omit에 들어가는 첫 번째 타입 인자가 union 타입이어서는 안 된다는 결론에 도달했다. 이 문제를 해결할 힌트는 Conditional Types에서 찾을 수 있었다.

type ModalOwnProps<T> = T extends ModalType ? Omit<ModalProps<T>, keyof BasicModalProps> : never

이게 어떻게 동작하게 되었는지 내가 이해한 대로 설명해보자면,

// 타입스크립트는
type ModalOwnProps<T extends ModalType> = Omit<ModalProps<T>, keyof BasicModalProps>
// 를 해석하면서, `T extends ModalType` 부분을 `T = ModalType`으로 가정하고 뒤의 타입을 추론한다. 그러니까,
type ModalOwnProps<T> = Omit<AlertProps | ConfirmProps, keyof BasicModalProps> // = {}
// 가 되었던 것이다.

// 그러나 conditional type은 input, 그러니까 T의 타입이 먼저 추론이 될 때 까지 타입을 계산하지 않는다.
type ModalOwnProps<T> = ???
// 상태로 존재하다가, T 가 'Alert'인게 확인된 순간에서야,
Omit<AlertProps, keyof BasicModalProps>
// 를 계산하게 되는 것이다.

결론

타입이 강력하게 잡힌 시스템을 얻기 위해서, 타입스크립트를 이해하기 위한 험난한 과정을 거쳤다. 특히나 union 타입은 굉장히 격한 혼돈의 도가니인 것 같다. 아직까지도 내가 제대로 이해하고 풀어쓴게 맞는지 좀 햇갈릴 정도이긴 하지만, 그래도 하나의 문제를 풀었다는 것에 성취감을 느낀다.

실제로 사용된 코드를 보고 싶다면