UnionToIntersection

A | B같은 union 타입을 받아서 A & B같은 intersection 타입을 만드는 유틸 타입입니다.

type UnionToIntersection<T> =
    (T extends any ? (x: T) => any : never) extends
    ((x: infer R) => any) ? R : never;

이 간단한 타입이 왜 필요한지 소개하고, 의도대로 동작하는 원리를 뜯어보겠습니다.

왜 필요한가

아래 간단한 코드를 봅시다.

interface TestA {
    type: 'A';
    value: string;
}

interface TestB {
    type: 'B';
    value: number;
}

interface TestC {
    type: 'C';
    value: number[];
    extra: string;
}

type Test = TestA | TestB | TestC;

declare function test<T extends Test>(type: T['type'], value: T['value']): T;

Test라는 tagged union 타입이 있습니다.

그 태그가 되는 type에 따라 다른 value를 받는 함수 test가 있다고 가정하겠습니다.

문제

test('A', "Hello world!"); // OK
test('A', 42); // Error expected

첫번째 인자로 A를 넣으면 두번째 인자로 string이 들어가는 것을 의도했습니다.

하지만 웬걸? 타입 오류가 발생하지 않습니다!

<T extends Test>에서 TTestA가 아닌 Test로 추론되었기 때문입니다.

해결(?) 방법

type TestMap = {
    'A': TestA;
    'B': TestB;
    'C': TestC;
};

declare function test<K extends keyof TestMap>(
    type: K,
    value: TestMap[K]['value']
): TestMap[K];

tagged union 대신 map 타입을 쓰면 간단히 그 문제를 해결할 수 있습니다.

대신 이 방법은 Test가 바뀔 때마다 TestMap을 개발자가 직접 바꿔줘야 합니다.

개발자는 DRY해야 합니다. Test가 바뀌면 TestMap이 자동으로 바뀌게 할 방법이 있을까요?

DRY한 고-급 해결 방법!

type ToMap<Union, Key extends keyof Union> =
    UnionToIntersection<
        Union extends { [K in Key]: infer I } ? I extends keyof any ?
                { [K in I]: Union } : never : never>;

type TestMap = ToMap<Test, 'type'>;

UnionToIntersection 타입을 활용한 ToMap 타입을 만들면 이 문제를 쉽게 해결할 수 있습니다!

이렇게 하면 귀찮게 type을 두 번 쓰지 않아도 되고,

실수로 type을 잘못 입력하거나 타입을 하나 빠뜨리는 실수도 방지할 수 있습니다.

원리

이제 UnionToIntersection 타입이 의도대로 잘 동작하는 원리를 뜯어보겠습니다.

간단히 만들기

타입이 보기가 좀 어려운데, 간단한 타입 세 개로 간단히 나누면 이렇게 됩니다.

type Dispatch<T> = (x: T) => any;

type ToDispatch<T> = T extends any ? Dispatch<T> : never;

type UnionToIntersection<T> =
    ToDispatch<T> extends Dispatch<infer R> ? R : never;

Dispatch는 워낙 간단하니 설명이 필요 없을 것 같네요.

ToDispatch도 간단한데, ToDispatch<A | B>Dispatch<A | B>가 아니라 Dispatch<A> | Dispatch<B>가 된다는 점만 알고 넘어가면 되겠습니다.

UnionToIntersection의 원리

함수 a는 A 타입을 받고 함수 b는 타입 B를 받는다고 가정하겠습니다.

declare function a(param: A): any;
declare function b(param: B): any;

여기서 ab의 타입은 각각 (param: A) => any, (param: B) => any가 됩니다.

(typeof a | typeof b) 타입의 함수를 호출할 때에는 인자로 무엇을 넘길 수 있을까요?

그런 인자가 있다면, 그 인자는 A도 만족하고 B도 만족해야 합니다.

즉, Dispatch<A> | Dispatch<B>Dispatch<A & B>가 됩니다.

이 점을 활용해서 infer 기능을 활용하면 union 타입을 intersection 타입으로 바꿀 수 있습니다.