⟦TypeScript〛DistributedOmit - 유틸리티 타입의 타입 분배

Discriminated Union을 유틸리티 타입과 함께 사용하다 Type Narrowing이 의도대로 동작하지 않은 경험이 있으신가요? 이런 현상은 왜 발생하는 걸까요? 이 글에서는 타입 분배가 무엇이고 일부 유틸리티 타입에서 타입 분배를 지원하지 않는 이유를 살펴봅니다.

2024-09-22에 씀

어떤 작업에 대한 상태를 나타낼 때, 아래와 같은 Discriminated Union을 활용하는 경우가 많다.

1type ProcessLoadingState = {
2 state: 'LOADING';
3};
4
5type ProcessSuccessState = {
6 state: 'SUCCESS';
7 data: ProcessData;
8};
9
10type ProcessErrorState = {
11 state: 'ERROR';
12 reason: ProcessErrorReason;
13};
14
15type ProcessState = { requestedAt: Date } & (
16 | ProcessLoadingState
17 | ProcessSuccessState
18 | ProcessErrorState
19);
20
21const processState = getProcessState();
22
23if (processState.state === 'SUCCESS') {
24 // OK
25 processState.data;
26 processState.requestedAt
27
28 // ⚠️ Error! Property 'reason' does not exist on type 'ProcessSuccessState'.
29 processState.reason;
30}

이처럼 Discriminated Union을 활용하면 공통 필드는 공유하면서도, 특정 리터럴 필드에 따라 타입을 좁혀 안전한 타입을 사용할 수 있다.

Discriminated Union을 사용하면 꼭 마주치게 되는 문제가 있는데, Omit 혹은 Pick 과 같은 타입스크립트 유틸을 사용하면 기존 의도대로 타입이 좁혀지지 않는 문제가 발생한다.

1// Omit으로 공통 필드인 requestedAt 필드를 제거한 타입을 만든다
2const getOmittedProcessState: () => Omit<ProcessState, 'requestedAt'> = () => { /* ... */ };
3const omittedProcessState = getOmittedProcessState();
4
5if (omittedProcessState.state === "SUCCESS") {
6 // ⚠️ Error! - 정상 / Omit 되었으므로 omittedProcessState에는 requestedAt 타입이 없다
7 omittedProcessState.requestedAt;
8
9 // ⚠️ Error! Property 'data' does not exist on type 'Omit<ProcessState, "requestedAt">'
10 // 타입이 의도대로 좁혀지지 않았다
11 omittedProcessState.data;
12}

이는 Omit 은 타입 분배가 적용되지 않는 유틸리티 타입이기 때문이다. 해결하려면 타입 분배가 적용되게 하면 된다. 아래와 같이 DistributiveOmit 타입을 만들어 사용하면 정상적으로 타입이 좁혀진다.

1// keyof도 마찬가지로 비분배 유틸이다
2type KeysOfUnion<ObjectType> = ObjectType extends unknown
3 ? keyof ObjectType
4 : never;
5
6type DistributedOmit<
7 ObjectType,
8 KeyType extends KeysOfUnion<ObjectType>,
9> = ObjectType extends unknown ? Omit<ObjectType, KeyType> : never;
10
11const getOmittedProcessState: () => DistributedOmit<ProcessState, 'requestedAt'> = () => { /* ... */ };
12const omittedProcessState = getOmittedProcessState();
13
14if (omittedProcessState.state === "SUCCESS") {
15 // ⚠️ Error - 정상 / Omit 되었으므로 omittedProcessState에는 requestedAt 타입이 없다
16 omittedProcessState.requestedAt;
17
18 // 에러가 발생하지 않는다
19 omittedProcessState.data;
20}

ObjectType extends unknown 은 항상 참이 되는 조건절이다. 왜 이런 당연한 조건부 타입을 거쳐야만 Discriminated Union이 정상적으로 동작하는 것일까?

타입 분배

타입을 분배한다는 것은, 타입 연산을 할 때 분배 법칙을 따른다는 것을 의미한다.

1// 분배 법칙
2(a + b) * x = a * x + b * x

타입스크립트에서 조건부 타입을 제네릭과 함께 사용할 때, 제네릭에 유니온 타입이 온다면 분배법칙이 적용된다. 예를 들면 아래와 같다.

1type ToArray<Type> = Type extends any ? Type[] : never;
2
3type StrArrOrNumArr = ToArray<string | number>; // = ToArray<string> | ToArray<number>
4
5// OK
6const arr1: StrArrOrNumArr = [1, 2, 3];
7const arr2: StrArrOrNumArr = ['a', 'b', 'c'];
8
9// Error! Type '(string | number)[]' is not assignable to type 'StrArrOrNumArr'.
10const arr3: StrArrOrNumArr = [1, 2, 'a', 'b'];

ToArray 타입은 Type이라는 제네릭 타입에 대한 조건부 타입이다. ToArray 타입의 제네릭으로 string | number 유니온을 넘겨주면, string, number 타입 각각에 ToArray를 적용하는 분배법칙이 적용되어 ToArray<string> | ToArray<number> 타입이 된다. 즉, 이 배열 타입은 string으로만 이루어졌거나 number로만 이루어진 배열이 된다.

1ToArray<string | number>
2=> ToArray<string> | ToArray<number>
3=> string[] | number[]

타입을 분배하지 않는다는 것은, 제네릭으로 유니온 타입을 넘겼을 때 유니온 각각의 타입을 처리하는 것이 아니기 때문에 유니온에 포함된 타입의 공통 필드만 처리한다는 것이다. 타입 분배를 막으려면 아래와 같이 조건절의 타입을 배열로 감싸면 된다.

1type NonDistributiveToArray<Type> = [Type] extends any ? Type[] : never;
2
3type StrOrNumArr = NonDistributiveToArray<string | number>;
4
5// OK
6const arr1: StrOrNumArr = [1, 2, 3];
7const arr2: StrOrNumArr = ['a', 'b', 'c'];
8const arr3: StrOrNumArr = [1, 2, 'a', 'b'];

StrOrNumArr 타입의 배열의 원소는 string, number 두 타입 모두 가능하다.

단, NonDistributiveToArray<string | number>NonDistributiveToArray<string> | NonDistributiveToArray<number> 와는 다르다.

1NonDistributiveToArray<string | number>
2=> (string | number)[]
3
4NonDistributiveToArray<string> | NonDistributiveToArray<number>
5=> string[] | number[]
6
7==> 둘은 완전히 다른 타입이다!

즉, NonDistributiveToArray 에서는 분배법칙이 성립되지 않는다.

타입을 분배하지 않는 타입을 분배하기

Discriminated Union을 사용하는 상황처럼, 타입 분배가 꼭 필요한 경우가 있다. 이런 경우에는 조건부 타입을 활용해 타입을 분배하도록 만들어 주면 된다.

1type KeysOfUnion<ObjectType> = ObjectType extends unknown
2 ? keyof ObjectType
3 : never;
4
5type DistributedOmit<
6 ObjectType,
7 KeyType extends KeysOfUnion<ObjectType>,
8> = ObjectType extends unknown ? Omit<ObjectType, KeyType> : never;
9
10// 그러면 아래와 같이 분배되는 타입이 된다
11DistributedOmit<A | B>
12=> DistributedOmit<A> | DistributedOmit<B>

unknown 타입은 모든 타입에 대응될 수 있는 타입이다. 따라서 ObjectType extends unknown 은 항상 참이다. 참이 되는 조건절을 활용해서, ObjectType 에 유니온 타입이 오더라도 타입 분배가 적용될 수 있도록 해주었다.

Omit은 왜 타입을 분배하지 않을까?

Discriminated Union에서 Omit이나 Pick을 수행해야 한다면 따로 Distributive 타입을 만들어 사용해야 의도대로 동작하게 된다. 왜 타입스크립트는 Omit, Pick에서 타입이 분배되지 않게 해둔 것일까?

타입스크립트에서 제공하는 Pick과 Omit 타입은 아래와 같이 구현돼있다.

1// https://github.com/microsoft/TypeScript/blob/88809467e8761e71483e2f4948ef411d8e447188/src/lib/es5.d.ts#L1581C1-L1583C3
2type Pick<T, K extends keyof T> = {
3 [P in K]: T[P];
4};
5
6type Omit<T, K extends keyof any> = Pick<T, Exclude<keyof T, K>>;

Pick은 맵드 타입(Mapped Types)을 기반으로 구현되어 있다. 맵드 타입은 프로퍼티의 키를 대괄호를 활용해 표현하며, 이를 활용해 파생된 타입을 만들 수 있다.

이때, 맵드 타입을 만들기 위해 타입스크립트의 keyofin 연산자를 사용한 것을 확인할 수 있는데, 이 연산자들은 분배가 발생하지 않는다. 유니온에 대하여 이 연산자를 사용하게 되면, 그 공통 속성에 대해서만 연산이 일어난다. 이것이 Omit을 거친 유니온 타입은 공통 속성만 접근할 수 있게 되는 이유이다.

이는 매우 복잡한 유니온 타입에 대하여 keyof, in 연산을 하게 될 경우 컴파일 성능 저하나 복잡도가 높아지는 문제가 발생할 수 있어, 유니온 타입이라면 공통되는 타입에 대해서만 연산을 수행하는 것으로 추측된다.

그러나 이런 이유와 별개로 타입스크립트가 내부적으로 Omit 타입에서 분배가 일어나도록 처리할 수 있었을 것이다. 그럼에도 따로 타입 분배를 처리하지 않은 이유는 타입스크립트 설계 목표를 기반으로 추측할 수 있다.

  1. Apply a sound or "provably correct" type system. Instead, strike a balance between correctness and productivity.

Omit이나 Pick은 Discriminated Union 타입 뿐만 아니라 모든 타입에서 동작할 수 있는 유틸리티 타입이고, 매우 단순한 동작을 하는 기본 유틸리티이다. 이런 타입에 타입 분배와 관련된 처리를 추가하여 복잡도를 높이는 것보다는, 필요한 최소한의 동작만 정의하는 것을 택하여 예측 가능성을 보장하기를 택한 것 같다.

참고 자료

프로필 사진

조예진

이전 포스트
Remix Session으로 JWT 토큰 관리하기