이벤트 발생 시, 현재 상태에 따라 처리가 분기되어야 하는 상황이 있었다. 예를 들면,


		type AppEvent = "claimPromotionItem"
		type UserRole = "admin" | "member" | "guest"
		type ServicePlan = "premium" | "free"
	

이벤트, 역할, 플랜으로 상태를 나누고, 이벤트 발생 시 유저의 역할과 이용 중인 플랜에 따라 처리를 분기하기 위한 핸들러를 정의해서,


		const handler = {
			claimPromotionItem: {
				admin: {
					default: () => console.log(`관리자 계정은 신청할 수 없습니다.`),
				},
				member: {
					premium: (postId: number) => console.log(`프리미엄 회원 ${postId}번 상품 요청.`),
					free: (postId: number, reason: string) => console.log(`일반 회원 ${postId}번 상품 요청. 사유: ${reason}`),
				},
				guest: {
					default: () => console.log("로그인 페이지로 이동"),
				},
			}
		}
	

handler["claimPromotionItem"]["member"]["free"](1, "더 좋은 서비스로 이동") 이런 식으로 쓰려고 하는 상황. 우선 실제 서비스에서 저렇게 하드코딩 할 일은 없고 참조로 가져오게 될 텐데,


		function getRoleStatusAfterComplecatedLogic(): UserRole {
			return "member"
		}
		function getPlanStatusWithReferenceLogic() : ServicePlan {
			return "premium"
		}

		const r = getRoleStatusAfterComplecatedLogic ()
		const p = getPlanStatusWithReferenceLogic ()
		handler["claimPromotionItem"][r][p](1, "더 좋은 서비스로 이동")
	

그러다보면 타입스크립트가 에러를 던진다.

Element implicitly has an 'any' type because expression of type 'string' can't be used to index type '{ admin: { default: () => void; }; member: { premium: (postId: number) => void; free: (postId: number, reason: string) => void; }; guest: { default: () => void; }; }'. No index signature with a parameter of type 'string' was found on type '{ admin: { default: () => void; }; member: { premium: (postId: number) => void; free: (postId: number, reason: string) => void; }; guest: { default: () => void; }; }'.

각 이벤트마다 모든 역할, 그리고 그 역할마다 또 모든 플랜을 모두 정의하면 저 에러는 사라지지만 당연히 그건 말도 안 된다. 필요한 것만 정의할 수 있어야 한다. 또 특정 이벤트는 특정 역할만 처리하거나 특정 역할만 다른 로직으로 처리하고 나머지 역할은 모두 같은 로직으로 처리하고 싶다. 일단 핸들러부터 타입을 명시해 보았다. 모든 타입이 아닌 필요한 것만 정의할 수 있도록 하는 방법으로 Partial을 사용하고 후에 공통로직을 위해 OR 연산으로 default를 넣었다.


		type HandlerMap = {
			[E in AppEvent]: Partial<
				Record<
					UserRole | "default",
						Partial<
							Record<ServicePlan | "default", (...args: any[]) => void | Promise<void>
						>
					>
				>
			>
		}
	

여기서 [E in AppEvent]는 Mapped Type 문법인데, 기존의 타입을 순회하면서 새로운 객체 타입을 만들어내는 문법이다. AppEvent에 정의된 모든 문자열을 키로 강제하고 그 값으로 역할과 플랜에 따른 메서드 구조를 할당하고 있다. 여기까지만 하고 다시 핸들러에 마우스를 올려다 대면, Object is possibly 'undefined'로 에러가 바뀌었다. 핸들러에 핸들러맵으로 타입을 명시해주어야 한다. 다음으로 디스패치 메서드를 정의.


		async function dispatch(event: AppEvent, role: UserRole, plan: ServicePlan, ...args: any[]) {
			const eventNode = handler[event]
			if (!eventNode) throw new Error(`error occur in handle, 이벤트: ${event}`)

			const roleNode = eventNode[role] || eventNode["default"]
			if (!roleNode) throw new Error(`error occur in handle, AppEvent: ${event}, UserRole: ${role}`)

			const hanlder = roleNode[plan] || roleNode["default"]
			if (!hanlder) throw new Error(`error occur in handle, AppEvent: ${event}, UserRole: ${role}, ServicePlan: ${plan}`)

			await hanlder(...args)
		}
	

저런 식으로 이벤트-역할-플랜에 해당하는 경우가 없을 때를 처리해주면 에러를 없앨 수 있다. 사용자 입력을 받는 것이 아닌 프로그래머틱한 에러이므로 assert를 써서 크래시를 내버리자.


		dispatch("claimPromotionItem", r, p, 1, "더 좋은 서비스로 이동")
	

핸들러 대신 디스패치 메서드를 쓰면 이제 에러는 없는데, 핸들러 객체의 플랜이 프리미엄일 경우 인자가 postId 하나여야 함에도 불구하고 지금 reason까지 두 개를 보냈는데 인텔리센스가 잡아내지 못한다. args 타입까지 안전하게 만들어야 하는데.. 여기서부터 서서히 해낼 수 없을 것 같다는 의심이 들기 시작한다..


		type handleEventWithArgs = {
			claimPromotionItem: {
				admin: {
					default: []
				},
				member: {
					premium: [postId: number],
					free: [postId: number, reason: string],
				},
				guest: {
					default: []
				},
			}
		}
	

각 경우의 인자를 별도 타입으로 관리하고,


		async function dispatch<
			E extends keyof handleEventWithArgs,
			R extends keyof handleEventWithArgs[E] & (UserRole | "default"),
			P extends keyof handleEventWithArgs[E][R] & (ServicePlan | "default")
		>(
			event: E,
			role: R,
			plan: P,
			...args: handleEventWithArgs[E][R][P] extends any[]
				? handleEventWithArgs[E][R][P]
				: never
		) {
			const eventNode = handler[event]
			if (!eventNode) throw new Error(`error occur in handle, 이벤트: ${event}`)

			const roleNode = eventNode[role] || eventNode["default"]
			if (!roleNode) throw new Error(`error occur in handle, AppEvent: ${event}, UserRole: ${role}`)

			const hanlder = roleNode[plan] || roleNode["default"]
			if (!hanlder) throw new Error(`error occur in handle, AppEvent: ${event}, UserRole: ${role}, ServicePlan: ${plan}`)

			await hanlder(...args)
		}
	

dispatch에 제네릭을 적용해 보았지만,

Argument of type 'ServicePlan' is not assignable to parameter of type 'never'. Type '"premium"' is not assignable to type 'never'.

role과 plan이 런타임에 결정되는 넓은 타입(UserRole, ServicePlan)으로 추론되는 순간 제네릭 추론이 붕괴되어 args가 never로 떨어진다. :'(

결국 args는 any[]로 두고, handler 정의 자체를 명세 삼아 호출부에서 맞춰주는 것으로 일단 사용하면서 후에 대안을 찾아보기로.