enum type 대신 union type으로 변경하기

enum 대신 object를 generic을 이용하여 union type으로 변경하기
앞쪽 내용은 기록용으로 조금 남들에겐 쓸데없는 소리가 글에 있으니, 제목에 해당되는 내용이 궁금하다면 바로 아래를 보자.

처음에 왜 enum을 왜 쓰게 되었는가.

사내에서 디자인 라이브러리를 만들다 보면, 사용자(개발자)의 생산성 향상에 많은 고민을 하게 된다. 각기 다른 컴포넌트에서 최대한 일관된 인터페이스를 제공해 주어 사용자가 새로운 컴포넌트가 추가되더라도 중요한 필드는 "예측 가능하도록" 만들고 싶었다. 물론 처음에는 enum 대신 union 타입을 사용했었다.
실제로 사용중인 Typography 컴포넌트에는 수많은 scales가 존재했다. 하지만 사용하다 보면 문제점이 있었다. Typography관련 컴포넌트만 보더라도 수많은 크기들이 존재했고, union type 으로 만들 시 위에서 보이는 많은 타입들을 아래와 같이 정의해 줘야 했다.
type Props = {
...
scale: "display1" | "display2" | ...
...
}
이러면 타입 코드가 많이 늘어나고 별거 아닌데도 괜스레 코드를 읽기 싫어졌다. 그리고 우리는 스토리북을 사용 중이었는데, 단순히 타입으로만 정의를 해버리면 저 타입을 컨트롤 패널에서 사용하기 위해 object로 코드를 추가적으로 만들어야 했다. 타입 수정/추가/삭제을 해야 한다면 object도 같이 수정해야 돼서 이를 수정하지 않는 개발자의 실수가 생길 여지가 많았다.
여기에 대한 대안으로 아래와 같이 타입 정의를 뺀다면 또 문제가 생긴다.
type ScaleType = "display1" | "display2" | ... ;
type Props = {
...
scale: ScaleType
...
}
안에 있는 타입이 뭔지를 모르게 된다(이 역시 스토리북에서 컨트롤 패널을 통한 테스트를 한다면 대부분의 경우 전용 object를 또 만들어 줘야 한다).
그래서 최종적으로 생각해 놓은 방법이 일단 enum을 쓰자였다. 일단 컴포넌트를 만드는 게 중요했기 때문에 더는 지체할 수가 없었다.
원활한 적용을 위해 컴포넌트 전체의 일관된 규칙을 만들었으며, 결론은 일단 나쁘지 않았다.
// ex)
enum TypoScales {
dispaly1 = "dispaly1",
...
}
type Props = {
...
scale: TypoScales;
...
}
// ---------
<Typo scale={TypoScales.display1} ... >제이콥</Typo>
일단 내가 정의한 규칙은 [컴포넌트 이름(파스칼 케이스) + 필드(복수형)]의 이름을 가진 enum을 정의하여 쓰며, 기본 속성에 대한 커스텀은 [기본 속성 이름(파스칼 케이스)]enum을 정의하는 것이었다.
// 실제로는 scale마다 정해진 font-weight가 있지만, 아래는 예시일 뿐이다.
<Typo fontWeight={FontWeight.black} ... />
<Button shape={ButtonShapes.rect} ... />
이것의 장점은 나름 명확했다. enum을 순환 가능한 객체로 만들 수 있기 때문에 storybook에서 컨트롤 패널에 넘겨 편했고, 하나의 enum으로만 관리되기 때문에 관리 포인트가 명확히 줄었다. 또한 사용자도 일관된 규칙을 통해 객체에 접근하기만 하면, value가 나왔기 때문에 이전 예시에 있는 TypoScales의 경우 타입 안에 뭐가 들어있는지는 IDE를 통해 확인하면 됐다.

뒤늦게 보이는 문제점들

1. enum이 숫자형에 대해 리버스 매핑(reverse mapping)이 된다는 것을 깜빡하고 있었다. 그러다 보니 enumobject로 가져다 변환할 때, "Object.keys({...FontWeight}).slice(Object.keys({...FontWeight}).length / 2)" 같은 불필요한 코드가 생겼다.
2. 우리의 디자인 라이브러리는 rollup을 번들러로 사용하고 있었다(초기에는 혼자 개발해야 하다 보니 과하게 설정이 복잡한 webpack과 싸울 시간이 없었고, 파셀 같은 제로 config는 작은 개인 프로젝트 이상에서 사용했을 때 좋은 경험을 한 적이 없어 고려하지 않았다. 또 다른 마이그레이션 예약이랄까... 역시나 찾아보니 파셀 2가 나왔다^^;).
rollup 번들러의 경우 IIFE에 대한 트리 쉐이킹(three shaking)을 지원하지 않는다(트리 쉐이킹이란 간단히 설명하자면 번들러가 사용하지 않는 모듈을 지우는 것을 말한다. 사용하지 않는 모듈을 쉐이킹 하여 쓸모없는 코드를 삭제한다는 의미이다). 갑자기 IIFE가 왜 나오는지 궁금할 수도 있겠지만, babeltypescript의 EnumIIFE로 변환시킨다.
JS의 경우 컴파일된 언어가 아니므로 IIFE의 경우 어떤 역할(작업)을 실행할지 모르기 때문에 이를 지우지 않는다.

다시 union 타입으로..

프로젝트가 어느 정도 완성되자 이제 고도화 시점이 되어 위와 같은 문제를 발견했다. 그리고 결론적으로는 union 타입으로 돌아가자는 의견이 나와 조금 더 다른 해결책을 찾아보게 되었다.
생각해 보니 나의 과거 글에서도 찾을 수 있듯이 arrayunion 타입으로 변경할 수 있었다. 그렇다면 object (key-value) 또한 변경할 수 있지 않을까? 이런 생각이 들 때쯤 동료 개발자가 라인 개발 블로그에서 해답 책을 가져왔다.(라인 개 블로그)
const a = ['a', 'b'] as const;
type A = typeof a[keyof typeof a]; // 'a' | 'b'
역시나 멋진 방법이었고 나는 그것을 여러 프로젝트에서 쓰기 위해 제네릭 타입으로 만들어 사용하기로 결정했고 아래와 같은 코드가 탄생했다.
type ValueOfUnion<T extends { [k: string]: unknown }> = T[keyof T];
const a = ['a', 'b'] as const;
type A = ValueOfUnion<typeof a>; // 'a' | 'b'
typescriptcondition type을 이용하여 간단히 만들어 보았다. 그리고 예전에 배열을 union 타입으로 만들 것이 생각나서 그것도 같이 만들었다.
type ArrayUnion<T extends ReadonlyArray<any>> = T[number];
const b = { a: 'a', b: 'b' } as const;
type B = ArrayUnion<typeof b>; // 'a' | 'b'
잘 동작하여, 이 둘을 하나로 추상화한 Union 타입으로 만들었다.
type Union<T> = T extends { [k: string]: unknown }
? ObjectUnion<T>
: T extends ReadonlyArray<any>
? ArrayUnion<T>
: never;
const a = ['a', 'b'] as const;
const b = { a: 'a', b: 'b' } as const;
type A = Union<typeof a>; // 'a' | 'b'
type B = Union<typeof b>; // 'a' | 'b'
위 코드가 답은 아닐 수 있기 때문에 더 좋은 방법은 항상 있을 수 있다.
현재의 내가 찾은 차선책이며, 프로젝트 고도화 시점에서 더 좋은 방법을 많이 찾아 동료 개발자들과 적용할 예정이다.

2021/03/22 타입 개선.

동료 개발자의 아이디어로 Union 코드를 다른 방법으로도 구현할 수 있었다.
일단 예전 코드를 먼저 보자.
export type ValueOfUnion<T extends { [k: string]: unknown }> = T[keyof T];
어차피 조건부 타입(condition type)을 사용할 거라면, 여기서 하나의 타입으로 만든 후 T[keyof T]의 코드처럼 타입 내부를 굳이 알 필요 없이 Typescript 2.8에서부터 도입된 infer라는 키워드를 통해 좀 더 쉽게 추론할 수 있게 만들 수 있다. 대신 infer 키워드는 조건부 타입에서만 사용할 수 있으므로 조건부 타입이 아니라면 사용이 어렵다.
export type Union<T> = T extends ReadonlyArray<any>
? T[number]
: T extends { [k: string]: infer U }
? U
: never;
그래서 이렇게 타입 정의를 끝내야지 했는데, 뒤에서 백엔드 개발자분이 한마디 하였다.
우리 자바에는 function interface도 있는데...
갑자기 문뜩 생각해보니 위 타입은 아무런 제약 없이 value로 함수까지 쓸 수가 있었다.
const a = {
a: 'a',
b: 'b',
c: () => 1,
} as const;
type A = Union<typeof a>; // 'a' | 'b' | () => 1
하지만 저 타입을 쓸 때 유니온 타입에는 문자와 숫자, boolean 이외에 오는 것은 우리가 의도한 바가 아니었다. 그래서 한 번 더 수정했다.
type ValueType = string | number | boolean;
export type Union<T extends { [k: string]: ValueType } | ReadonlyArray<ValueType>> = T extends ReadonlyArray<ValueType>
? T[number]
: T extends { [k: string]: infer U }
? U
: never;
실제 코드는 공통된 타입을 좀 더 정리하긴 했지만, 대략 이러하다. 에러가 나오는걸 보니 잘 작동된다.