ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • TypeScript 초보자를 위한 Mapped Types 활용하여 깔끔한 인터페이스 만들기
    Javascript 2024. 9. 20. 18:44

    TypeScript 초보자를 위한 Mapped Types 활용하여 깔끔한 인터페이스 만들기

    안녕하세요 여러분! 오늘은 제가 구글링을 통해 알게 된 TypeScript의 Mapped Types에 대해 쉽게 풀어보려고 합니다.

     

    프로그래밍을 막 시작한 분들도 이해할 수 있도록 최대한 친근하고 간단하게 설명해드릴게요.

    TypeScript의 교차 타입(Intersection Type)이란?

    TypeScript에는 교차 타입(Intersection Type)이라는 기능이 있는데요, 이는 T & U와 같은 구문으로 표현됩니다.

     

    쉽게 말해, "T이면서 U인 타입"을 의미합니다.

     

    구조적 부분 타입과 교차 타입의 이해

    처음 들어보는 분들은 "교차 타입이 뭐야?"라고 궁금해할 수 있는데요.

     

    실제로 교차 타입은 주로 객체 타입을 합칠 때 많이 사용됩니다.

     

    예를 들어, T{ foo: string } 타입이고 U{ bar: number } 타입이라면, T & U{ foo: string; bar: number } 타입이 됩니다.

    type T = { foo: string };
    type U = { bar: number };
    
    type I = T & U;
    // 할당 가능
    const obj: I = {
      foo: "foo",
      bar: 123
    };

     

    여기서 T & U는 T와 U의 모든 프로퍼티를 가진 하나의 객체 타입으로 볼 수 있습니다.

     

    실제로 많은 경우에 이러한 직관적인 설명이 교차 타입을 이해하는 데 도움이 됩니다.

     

    이 결과가 나오는 이유는 구조적 부분 타입(Structural Subtyping) 덕분인데요.

    T 타입은 "문자열 타입의 프로퍼티 foo를 가진 객체"를 의미합니다.

     

    구조적 부분 타입의 특성상, 이 타입은 foo 외의 다른 프로퍼티에는 영향을 미치지 않습니다.

     

    따라서 { foo: "foo", bar: 123 }과 같은 객체도 T 타입을 만족하게 됩니다.

     

    마찬가지로 U 타입도 "숫자 타입의 프로퍼티 bar를 가진 객체"를 의미합니다.

     

    결국, T & U는 "문자열 타입의 프로퍼티 foo를 가지며, 숫자 타입의 프로퍼티 bar도 가진다"는 조건을 모두 만족하는 타입입니다.

    { foo: "foo", bar: 123 }는 이러한 조건을 확실히 충족하죠.

     

    교차 타입과 제네릭(Generics)의 만남

    이번에는 제네릭과 교차 타입을 결합해서 좀 더 흥미로운 예제를 만들어보겠습니다.

     

    예를 들어, ImmutableRecord를 다루는 empty 함수와 set 함수를 정의해보겠습니다.

     

    여기서는 구현은 생략하고 타입 정의만 진행할게요.

    declare const recordBrand: unique symbol;
    
    type ImmutableRecord<Data> = {
        [recordBrand]: Data
    };
    
    /** 새로운 빈 ImmutableRecord를 반환합니다. */
    declare function empty(): ImmutableRecord<{}>;
    
    /**
     * 받은 ImmutableRecord에 새로운 키와 값을 추가하여
     * 새로운 ImmutableRecord를 반환합니다.
     */
    declare function set<Data, Key extends string, Value>(
        map: ImmutableRecord<Data>,
        key: Key,
        value: Value,
    ): ImmutableRecord<Data & {
        [K in Key]: Value
    }>;
    
    /**
     * 받은 ImmutableRecord에서 지정된 키의 값을 반환합니다.
     */
    declare function get<Data, Key extends keyof Data>(
        map: ImmutableRecord<Data>,
        key: Key
    ): Data[Key];
    
    // ImmutableRecord<{}>
    const m1 = empty();
    // ImmutableRecord<{ foo: string }>
    const m2 = set(m1, "foo", "pikachu");
    // ImmutableRecord<{ foo: string } & { bar: number }>
    const m3 = set(m2, "bar", 12345);
    
    // string
    const v1 = get(m3, "foo");
    // number
    const v2 = get(m3, "bar");

     

    empty 함수는 빈 레코드(ImmutableRecord<{}>)를 생성하여 반환합니다.

    set 함수는 현재 레코드와 새로운 데이터를 받아서 이를 추가한 새로운 ImmutableRecord를 반환하며, get 함수는 지정된 키의 데이터를 가져옵니다.

     

    여기서 set 함수의 반환 타입에 교차 타입이 사용되었습니다.

    Data & { [K in Key]: Value }Data{ [K in Key]: Value }를 합친 객체 타입을 의미합니다.

     

    오른쪽에 있는 것은 Mapped Type으로, 여기서는 Value를 값으로 하는 Key라는 이름의 키를 하나만 가진 객체 타입을 표현하고 있습니다.

     

    덮어쓰기 문제와 해결 방법

    하지만, 이런 구현에는 큰 문제점이 하나 있습니다.

     

    바로 데이터 덮어쓰기를 표현할 수 없다는 것이죠. 동일한 키에 두 번 set을 호출하면 이전 타입과 새로운 타입이 섞여버립니다.

     

    // ImmutableRecord<{}>
    const m1 = empty();
    // ImmutableRecord<{ foo: string }>
    const m2 = set(m1, "foo", "pikachu");
    // ImmutableRecord<{ foo: string } & { foo: number }>
    const m3 = set(m2, "foo", 12345);
    
    // never
    const v1 = get(m3, "foo");

     

    여기서 m3ImmutableRecord<{ foo: string } & { foo: number }>가 되는데, 이는 foostring 타입이면서 number 타입인 string & number 타입을 의미합니다.

     

    문자열이면서 숫자인 값은 존재하지 않으므로, 이는 never 타입으로 해결됩니다. 따라서 v1의 타입이 never이 됩니다.

     

    이는 의도한 결과가 아니죠. 올바르게 작동하려면 같은 키에 두 번 set을 호출할 때 첫 번째 foo의 타입은 버려져야 합니다.

     

    이를 구현하기 위해서는 Data에서 foo를 제거한 후 새로운 타입을 추가해야 합니다.

    /**
     * 받은 ImmutableRecord에서 Data에서 key를 제거하고,
     * 새로운 키와 값을 추가하여 새로운 ImmutableRecord를 반환합니다.
     */
    declare function set<Data, Key extends string, Value>(
        map: ImmutableRecord<Data>,
        key: Key,
        value: Value,
    ): ImmutableRecord<Omit<Data, Key> & {
        [K in Key]: Value
    }>;

     

    변경된 점은 반환 타입의 & 왼쪽이 Omit<Data, Key>로 변경된 것입니다.

     

    이는 Data에서 Key라는 이름의 키를 제거한 타입을 의미합니다. Omit은 TypeScript의 표준 라이브러리에 포함되어 있습니다.

     

    이로써 직관적으로 동작하여, v1number 타입이 됩니다.

    // ImmutableRecord<{}>
    const m1 = empty();
    // ImmutableRecord<Pick<{}, never> & { foo: string }>
    const m2 = set(m1, "foo", "pikachu");
    // ImmutableRecord<Pick<Pick<{}, never> & { foo: string }, never> & { foo: number }>
    const m3 = set(m2, "foo", 12345);
    
    // number
    const v1 = get(m3, "foo");

     

    타입의 가독성 문제

     

    하지만, 여기서 새로운 문제가 발생했습니다.

    m3의 타입을 확인해보면 ImmutableRecord<Pick<Pick<{}, never> & { foo: string }, never> & { foo: number }>라고 표시됩니다.

     

    연산의 결과는 실제로는 ImmutableRecord<{ foo: number }>이어야 하지만, Pick&가 포함된 복잡한 타입이 되어버립니다.

     

    이처럼 복잡한 연산을 하게 되면 타입이 보기 어려워지는 문제가 발생합니다.

     

    타입은 코드를 읽는 사람을 도와주는 역할을 하기 때문에, 읽기 어려운 타입은 바람직하지 않습니다.

     

    이러한 문제를 해결하는 방법 중 하나는 Flatten 타입을 사용하는 것입니다.

    type Flatten<T> = {
      [K in keyof T]: T[K];
    };

     

    이 타입은 Mapped Type을 사용하여 객체 타입의 내부를 계산해주는 효과를 가집니다.

     

    예를 들어, 단순한 교차 타입에도 Flatten을 적용하면 더 깔끔한 하나의 객체 타입으로 만들 수 있습니다.

    type T = { foo: string } & { bar: number };
    // type U = { foo: string; bar: number }
    type U = Flatten<T>;

     

    이처럼, 두 개의 객체의 교차 타입에 Flatten을 적용하면, 의미상 동일한 하나의 객체 타입이 되어 읽기 쉬워집니다.

     

    이를 set 함수의 반환 타입에 적용하면 결과가 더 깔끔해질 것 같습니다.

     

    구체적으로는 다음과 같습니다.

    /**
     * 받은 ImmutableRecord에서 Data에서 key를 제거하고,
     * 새로운 키와 값을 추가한 후 Flatten을 적용하여
     * 더 깔끔한 ImmutableRecord를 반환합니다.
     */
    declare function set<Data, Key extends string, Value>(
        map: ImmutableRecord<Data>,
        key: Key,
        value: Value,
    ): ImmutableRecord<Flatten<Omit<Data, Key> & {
        [K in Key]: Value
    }>>;

     

    이렇게 하면 m3의 타입도 더 간결해집니다.

    // ImmutableRecord<{}>
    const m1 = empty();
    // ImmutableRecord<Flatten<Pick<{}, never> & { foo: string }>>
    const m2 = set(m1, "foo", "pikachu");
    // ImmutableRecord<Flatten<Pick<Pick<{}, never> & { foo: string }, never> & { foo: number }>>
    const m3 = set(m2, "foo", 12345);
    Flatten.png

     

    하지만, 결과를 보면 m3의 타입에 Flatten이 그대로 남아 있어 결과가 예상대로 깔끔해지지 않았습니다.

     

    이처럼, Flatten은 복잡한 타입에서는 기대한 효과를 발휘하지 못할 수 있습니다.

     

    자체 Mapped Type 사용하기

    위의 문제를 해결하기 위해, Flatten이라는 이름을 사용하지 않고 함수의 반환 타입 내에서 직접 Mapped Type을 사용하는 방법이 있습니다.

    /**
     * 받은 ImmutableRecord에서 Data에서 key를 제거하고,
     * 새로운 키와 값을 추가하는 Mapped Type을 사용하여
     * 더 깔끔한 ImmutableRecord를 반환합니다.
     */
    declare function set<Data, Key extends string, Value>(
        map: ImmutableRecord<Data>,
        key: Key,
        value: Value,
    ): ImmutableRecord<
      Key extends keyof Data
        ? {
            [K in keyof Data]: K extends Key ? Value : Data[K]
          }
        : {
            [K in keyof Data | Key]:
              K extends keyof Data
                ? Data[K]
                : Value
          }>;

     

    이제, m3의 타입이 더 명확해졌습니다.

    // ImmutableRecord<{}>
    const m1 = empty();
    // ImmutableRecord<{ foo: string }>
    const m2 = set(m1, "foo", "pikachu");
    // ImmutableRecord<{ foo: number }>
    const m3 = set(m2, "foo", 12345);
    
    // number
    const v1 = get(m3, "foo");

     

    이 정의를 통해 m3의 타입이 ImmutableRecord<{ foo: number }>로 더 명확해졌습니다.

    결론

    복잡한 타입 계산을 할 때는 결과 타입의 가독성을 신경 써야 합니다.

     

    본 글에서 소개한 것처럼 함수의 반환 타입에 직접 Mapped Type을 사용하여 타입을 깔끔하게 유지하는 기술이 매우 유용합니다.

     

    정확히는, Key가 유니언 타입일 때는 하나의 타입이 아닌 여러 타입이 될 수 있습니다.

     


     

Designed by Tistory.