주입받은 객체의 프로퍼티인 selector Atom과 action Atom을 각각 state화 하는 wrapping 함수를 만들고자 한다.
이 과정에서 매개변수로 전달받은 selectorAtom과 actionAtom의 타입 추론을 유지하기 위해, 주입받은 타입의 필드들을 동적으로 추론
해야 한다.
import { Atom, WritableAtom } from 'jotai';
export type ExtractSelectors<T> = {
[P in keyof T]: T[P] extends Atom<infer V> ? V : never;
};
export type ExtractActions<T> = {
[P in keyof T]: T[P] extends WritableAtom<any, infer U, void>
? (param: U[0]) => void
: never;
};
규모가 조금씩 커져 나아가 코드가 복잡해지자 위처럼 타입 정의 파일을 따로 분리하였는데 이것이 화근이었다.
트리 쉐이킹 사이드 이펙트를 고려하지 않았기 때문이다. 트리 쉐이킹 과정에서 사용되지 않는 코드가 제거되는데, 동적으로 객체의 field의 값을 확인하는 함수가 제거되어 타입 추론에 문제가 발생한 경우이다.
import { ExtractSelectors, ExtractActions } from 'types';
export const useManager = <T extends AtomManager<any>>(manager: T) => {
const selectors = Object.fromEntries(
Object.entries(manager.selectors).map(([key, atom]) => [
key,
useAtomValue(atom),
])
) as ExtractSelectors<T['selectors']>;
const actions = Object.fromEntries(
Object.entries(manager.actions).map(([key, actionAtom]) => [
key,
useSetAtom(actionAtom),
])
) as ExtractActions<T['actions']>;
return { selectors, actions };
};
위 코드는 번들링을 진행하기 전, 정상적으로 타입을 추론했으나 패키지 배포한 이후, type이 selectors와 actions의 type이 각각 ExtractActions<T['actions']>
와 ExtractActions<T['actions']>
로 고정되는 이슈가 발생했다. (사용처에선 전부 any. 즉, 타입 추론이 사라짐.)
고민을 하다 결국 트리쉐이킹의 사이드 이펙트인 것인 것을 고려하게 되었고 import한 type들을 사용처 내부로 다시 불러와 이슈를 해결하였다.
간단하지만 재밌는 고민 점이 하나 더 생겼다. DX와 UX가 음의 상관관계를 갖는 의사결정을 마주하였기 때문이다.
type ExtractSelectors<T> = {
[P in keyof T]: T[P] extends Atom<infer V> ? V : never;
};
type ExtractActions<T> = {
[P in keyof T]: T[P] extends WritableAtom<any, infer U, void>
? (param: U[0]) => void
: never;
};
export const useManager = <T extends AtomManager<any>>(manager: T) => {
const selectors = Object.fromEntries(
Object.entries(manager.selectors).map(([key, atom]) => [
key,
useAtomValue(atom),
])
) as ExtractSelectors<T['selectors']>;
const actions = Object.fromEntries(
Object.entries(manager.actions).map(([key, actionAtom]) => [
key,
useSetAtom(actionAtom),
])
) as ExtractActions<T['actions']>;
return { selectors, actions };
};
이런 방식으로 DX가 좋게 Selectors와 Actions를 Extract하는 type을 분리한 뒤, 코드를 작성 및 배포한다면, 사용자가 useManager를 hover했을 때 아래의 type추론을 확인하게 된다.
(alias) useManager<Playlist>(manager: Playlist): { selectors: ExtractSelectors<{ playlist: Atom<Music[]>; currentMusic: Atom<Music>; }>; actions: ExtractActions<...>; } import useManager
그냥 그런가보다 할 수 있지만, 사용자 입장에선 useManager 내부의 ExtractSelectors
로 인해 타입 추론에 대한 블랙박스 Layer가 발생하고 어떻게 값들이 사용되는지 알기 어렵다는 것이다.
이번엔 DX를 고려하지 않고 직접 타입을 assertion해보자.
export const useManager = <T extends AtomManager<any>>(manager: T) => {
const selectors = Object.fromEntries(
Object.entries(manager.selectors).map(([key, atom]) => [
key,
useAtomValue(atom),
])
) as {
[P in keyof T['selectors']]: T['selectors'][P] extends Atom<infer V>
? V
: never;
};
const actions = Object.fromEntries(
Object.entries(manager.actions).map(([key, actionAtom]) => [
key,
useSetAtom(actionAtom),
])
) as {
[P in keyof T['actions']]: T['actions'][P] extends WritableAtom<
any,
infer U,
void
>
? (param: U[0]) => void
: never;
};
return { selectors, actions };
};
조금은 읽기 어려운 코드가 되었다.
하지만 사용자들이 보는 type 추론을 살펴보자.
(alias) useManager<Playlist>(manager: Playlist): { selectors: { playlist: Music[]; currentMusic: Music; }; actions: { add: (param: Music) => void; remove: (param: Music) => void; play: (param: number) => void; next: (param: undefined) => void; prev: (param: undefined) => void; }; }
깔끔해졌다. useManager에 hover할 경우, 한 눈에 어떤 selector와 actions를 사용할 수 있을지, 매개변수가 무엇인지 한 번에 알 수 있다.
당연히 이건 혼자 개발하는 라이브러리이기 때문에 후자를 택하여 배포하였다.
wrapping 함수에서 매개변수로 전달받은 객체의 타입을 동적으로 추론하여 return 값에서도 원본의 타입을 사용하는 것은 2달 전에도 시도했지만 드디어 첫 성공을 이루게 되었다. 🥳
주말이 통으로 사라졌지만 ReturnType이나 infer을 사용해 조금 더 TS를 유용하게 쓸 수 있었던 값진 시간이었다.