Typed Modal system
Deprecated Warning
아래 글은 @reactleaf/modal 라이브러리로 구현하면서 대체되었다.
이전의 생각들을 보존해두기 위해 남겨둔다.
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 타입은 굉장히 격한 혼돈의 도가니인 것 같다. 아직까지도 내가 제대로 이해하고 풀어쓴게 맞는지 좀 햇갈릴 정도이긴 하지만, 그래도 하나의 문제를 풀었다는 것에 성취감을 느낀다.