티스토리 뷰

반응형


안녕하세요. 오늘의 주제는 타입스크립트입니다. 프론트엔드 개발을 하면서 타입스크립트의 "어떤 특성"까지 활용해 보셨나요?

 

일반적으로 자주 사용하는 Union, Generic, Tuple, index signature 등 타입을 표현하는 기법들은 많이 알려져 있지만, 이번 글에서는 이러한 문법들을 활용해 조금 더 깊이 있는 타입 구성 방법을 살펴보려 합니다.

 

평소 타입스크립트를 쓰면서 “이런 것도 되네?” 하고 신기하거나, 몰랐다가 알게 된 유용한 기법들을 개인적으로 정리해두고 있었는데요. 그중에서 인상에 더욱 남았던 특성들 위주로 직접 구현해보았던 예제 타입과 함께 정리해봤습니다.

 

예제에서는 재귀 타입, infer, 조건부 타입 분배 등 다소 복잡한 문법들이 사용됩니다.
이 글에서는 해당 문법 자체에 대한 설명은 생략하고 있어, 어느 정도 익숙하신 분들이라면 더 수월하게 읽으실 수 있을 것 같습니다.

 

 

타입스크립트 Skill List

  1. Union 타입을 조건부 타입으로 활용하는 기법 - Permutation
  2. 함수 시그니처를 활용한 양방향 타입 비교 - Equal
  3. 튜플을 이용해 함수 인자를 추가하는 방법 - AppendArgument (feat. Tuple)
  4. 타입을 평탄화하는 스킬 - AppendToObject (feat. Omit)
  5. as로 Mapped Type 활용하기 - MyOmit (feat. Homomoerphic)

 


타입스크립트의 특성

타입스크립트는 "범위" 비교로 결과를 도출해야 합니다.

가끔 타입스크립트를 깊이 파고들다 보면,
“왜 이렇게 복잡하게 돌아가지?” 싶은 순간이 종종 찾아옵니다. (물론 그게 타입스크립트의 매력이기도 하지만,,)

저는 그런 상황을 마주할 때 이렇게 생각하며 그냥 타입스크립트 특성을 받아드립니다.

"자바스크립트는 값을 비교하지만, 타입스크립트는 '범위'를 비교한다."

 

즉, 타입스크립트는 두 값이 같은지를 비교하는 게 아니라,
한 타입이 다른 타입에 포함되는지를 기준으로 조건을 판단합니다.

const a = '1';
const b = 1;
console.log(a === b); // false

// 타입스크립트에서는:
type A = '1';
type B = 1;
type Result = A extends B ? true : false; // false

 

그래서 때로는 우리가 예상한 것보다 복잡한 방식으로 타입을 구성해야만 원하는 결과를 얻을 수 있습니다.

특히 재귀적으로 타입을 구성하거나, 여러 속성이 얽힌 객체 타입을 처리할 때 이런 특성이 더 분명히 드러납니다.

 

앞으로 소개할 내용들이 조금 낯설게 느껴지더라도, 범위 비교를 위한 과정임을 생각해 주시고 봐주시면 좀 더 와닿을 부분들이라고 생각해 스킬을 소개하기 전 이 부분을 말씀드리고 넘어가면 좋겠다고 생각했습니다.

 


1. Union 타입을 조건부 타입으로 활용하는 기법 - Permutation

타입스크립트의 조건부 타입은 제네릭 타입에 유니언 타입에 적용될 경우, 각 요소에 대해 자동으로 분배(distribution)되는 특성을 갖고 있습니다. 이걸 분배 조건부 타입 Distributive Conditional Types 이라고 하는데요.  

 

예를 들어 T extends T와 같은 조건을 만들면, T = "A" | "B" | "C"일 경우 다음과 같이 작동합니다:

  • "A" extends "A"
  • "B" extends "B"
  • "C" extends "C"

이렇게 분배가 되는 게 일반적이지만, 분배를 막고 싶다면 아래와 같이 []기호로 감싸 선언하면 됩니다.

[T] extends [T]

 

예를 들어 Union타입으로 순열을 만들고 싶다면 어떻게 해야 할까요?

type Case1 = Permutation<'A' | 'B' | 'C'>
// expect:
| ["A", "B", "C"]
| ["A", "C", "B"]
| ["B", "A", "C"]
| ["B", "C", "A"]
| ["C", "A", "B"]
| ["C", "B", "A"]


type Case2 = Permutation<boolean> // expect: [false, true] | [true, false]

type Case3 = Permutation<never>, // expect: []

 

Permutation Type

앞서 소개드린 분배 조건부 타입을 활용하면 아래와 같이 만들 수 있습니다.

type Permutation<T, Acc = T> = [T] extends [never]
  ? []
  : Acc extends Acc
    ? [Acc, ...Permutation<Exclude<T, Acc>>]
    : never;

 

 

Acc extends Acc에서 T가 유니언 타입('A' | 'B' | 'C') 일 때

Acc extends Acc는 제네릭을 사용함으로써 분배 조건부 타입이기 때문에 Acc = 'A' | 'B' | 'C'가 'A', 'B', 'C' 각각으로 나뉘어서 재귀를 수행하게 됩니다.

= ['A', ...Permutation<'B' | 'C'>]
= ['A', 'B', ...Permutation<'C'>]
= ['A', 'B', 'C']
...

 

[T] extends [never]은 재귀를 종료시키기 위한 조건입니다.

Exclude는 아래처럼 구성되어 있어서 Exclude <T, Acc>를 계속하다 보면 결국 T = never가 됩니다. 

 

type Exclude<T, U> = T extends U ? never : T;

 

함수에서도 재귀의 종료조건은 아주 중요하듯이, 타입스크립트에서도 재귀를 쓰면 종료조건을 잘 넣어줘어야합니다.

위에서 []를 쓰면 분산을 막을 수 있다고 말씀드렸듯 , never가 오는 시점에서 조건부는 더 이상 필요 없기 때문에 [T] extends [never]를 써서 재귀를 종료시켜 줍니다. 


 

2. 함수 시그니처를 활용한 양방향 타입 비교 - Equal

A와, B가 정확히 동일한 타입인지, 그러니까 자바스크립트에서 1 === "1" 의 결과처럼 타입이 서로 같은지 도출하는 타입은 어떻게 구성할 수 있을까요? 

 

타입스크립트에서 A extends B는 A가 B의 부분 집합인지, 즉 포함 여부만을 판단합니다. 그런데 우리는 종종 A와 B가 정확히 동일한 타입인지 알고 싶을 때가 있습니다.

 

마치 자바스크립트에서 1 === "1"과 같은 엄격한 동치 비교를 타입 수준에서도 하고 싶은 거죠.

그럴 때 사용하는 테크닉이 바로 함수 시그니처 기반의 비교입니다.

 

Equal Type

아래는 제가 잘 이용하는 사이트인 type challenges 코드에서 테스팅 목적으로 항상 사용되는 Equal이라는 타입입니다.

type Equal<A, B> = (<T>() => T extends A ? 1 : 2) extends
                   (<T>() => T extends B ? 1 : 2)
  ? true
  : false;
  
  
type A = Equal<"1", 1>, true> // false
type B = Equal<"1", 1>, false> // true

 

저는 처음에 이 타입을 보고 결국에는 1 extends 1 or 2 extends 2로 비교를 하는 건가?라고 헤맸던 기억이 나네요.

 

여기서 숫자 1이나 2는 어떤 값이든 상관없습니다. 제네릭 T도 비교를 위한 도구일 뿐, 실제로 타입 시스템에 값을 넣어보는 테스트 케이스가 아닙니다.

 

중요한 건 두 타입의 조건 평가 결과가 완전히 일치하느냐입니다.

 

좀 더 쉽게 설명하면, 아래 두 함수 타입은 겉보기에는 비슷해 보이지만, 타입스크립트는 함수 전체의 구조(시그니처)를 비교합니다.

(<T>() => T extends "1" ? 1 : 2) extends
(<T>() => T extends 1 ? 1 : 2)

 

 

즉, 함수 전체가 같아야 한다는 건 단순히 1이냐 2냐를 비교하는 것이 아니라, "T에 어떤 타입이 들어와도 두 함수가 항상 같은 결과를 낼 수 있느냐"를 기준으로 삼는다는 뜻입니다.

 

그래서 위처럼 "1" 1은 타입이 다르기 때문에 결과적으로 두 함수 시그니처는 같지 않다고 판단되는 것입니다.

 


3. 튜플을 이용해 특정 이름의 함수 인자를 추가하는 방법 - AppendArgument

 

타입스크립트에서 함수의 rest parameters를 표현할 때 아래처럼 표현할 수 있습니다. (ts 공식문서 예제)

function multiply(n: number, ...m: number[]) {
  return m.map((x) => n * x);
}
// 'a' gets value [10, 20, 30, 40]
const a = multiply(10, 1, 2, 3, 4);

 

이처럼 함수의 매개변수 타입을 튜플 형태로 정의하고, 마지막에 스프레드(...) 연산자를 써서 유연하게 인자를 받을 수 있습니다.

 

AppendArgument Type

그렇다면, 이런 함수 타입에 새로운 인자를 추가하고 싶을 때는 어떻게 해야 할까요?

type A = AppendArgument<(a: number, b: string) => number, boolean>
// expect :
// (a: number, b: string, x: boolean) => number

 

 

마찬가지로 튜플(Tuple)과 스프레드 문법(Spread)을 활용해 타입을 확장할 수 있습니다.

type AppendArgument<Fn extends (...args: any) => any, A> =
  Fn extends (...args: infer Args) => infer Return
    ? (...args: [...Args, A]) => Return
    : never;

 

 

그런데 위처럼 선언하면 TS는 자동으로 해당 인자를 뒤에 추가해 줍니다. 타입에는 전혀 문제가 없지만,, 인자의 이름이 arg2로 되는 게 불편하지 않으신가요?

AppendArgument<(a: number, b: string) => void, boolean>
// 결과:
// (a: number, b: string, arg2: boolean) => void

 

 

AppendArgument type의 인자 타입 부분을 풀어보면 아래와 같이 됩니다.

type Args = [a: number, b: string];
type Extended = [...Args, arg2: boolean];

// 위는 아래와 동일하게 해석됩니다:
type ExtendedEquivalent = (a: number, b: string, arg2: boolean) => void;

 

실전에서는 타입 디버깅 시 arg2라고 되어있으면 가독성이 떨어질 것 같습니다. 좀 더 나은 타입을 만들기 위해 인자의 이름을 x처럼 직접 명시하고 싶다면?

 

arg2를 x로 치환해주기만 하면 되겠죠.

 (...args: [...Args, x: A]) => Return

 

위 타입은 x: A는 “이 인자의 이름은 x고 타입은 A입니다”라는 의미이고, 이처럼 타입스크립트는 함수 시그니처 안에서도 이름을 타입 수준에서 표현할 수 있게 해 줍니다.

 


4.  object 타입을 평탄화하는 스킬 - AppendToObject (feat. Omit)

object로 구성된 Color type에 어떠한 key와 value를 추가하기 편한 타입을 만들고 싶으면 어떻게 해야 할까요?

type Color = {
  key: string;
  value: "green" | "red";
};

type ColorSystem = AppendToObject<Color, "isPrimitive", boolean>;

const B: ColorSystem = {
  key: "x-1",
  value: "green",
  isPrimitive: true, // added!
};

 

AppendToObject Type

아주 간단하게는 이렇게 만들 수도 있습니다.

type AppendToObject<T, U extends PropertyKey, V> = T & Record<U, V>;

 

하지만 위처럼 선언하면 타입은 아래와 같이 나옵니다. 

type ColorSystem = Color & Record<"isPrimitive", boolean>

 

 

이렇게 & 연산자로 병합한 타입은 타입스크립트가 타입을 내부적으로 병합하긴 하지만, 자동으로 평탄화(flatten) 하지는 않습니다. 

즉, 위의 타입은 Color 내부 타입을 다시 들여다봐야 하는 불편함이 있습니다.

 

만약 아래처럼 Flat 하게 평탄화된 타입을 추출하려면 어떻게 해야 할까요? 

type ColorSystem = {
    key: string;
    value: "green" | "red";
    isPrimitive: boolean;
}

 

 

이럴 때 타입을 평탄하게 만들고 싶다면, 다음과 같이 Mapped Type를 사용하여 강제로 펼쳐 다시 구성해야 합니다.

type AppendToObject<T, U extends PropertyKey, V> = {
  [K in keyof (T & Record<U, V>)]: (T & Record<U, V>)[K];
};

 

이렇게 하면 둘 다 key를 다시 순회 하기 때문에, 타입스크립트가 평탄한 형태로 출력해 줍니다.

 

또는 좀 더 간결하게 Omit을 활용해도 같은 결과를 얻을 수 있습니다. 아무 속성도 제거하지 않는다면 Omit <T, never>은 결국 T와 동일하니까요.

type AppendToObject<T, U extends PropertyKey, V> = Omit<
  T & Record<U, V>,
  never
>;

 

이게 가능한 이유는 Omit이 Pick과 Exclude를 사용해 구성되어 있고, Pick에서 이미 맵드 타입(Mapped Type)으로 구성되어 있기 때문입니다.

 

Exclude <keyof T, never>는 결국 아무 키도 제외하지 않기 때문에 keyof T와 동일하게 됩니다.

그래서 전체 키를 유지한 채 Pick으로 다시 타입을 재구성하는 효과를 얻게 되는 것입니다.

type Omit<T, K extends keyof any> = Pick<T, Exclude<keyof T, K>>;

type Pick<T, K extends keyof T> = {
    [P in K]: T[P];
};

type Exclude<T, U> = T extends U ? never : T;

 

 


 

5. as로 Mapped Type 활용하기 - MyOmit

위에서도 소개를 했지만 Omit은 타입스크립트에서 매우 자주 사용하는 유틸리티 타입입니다. 특정 키를 제거한 객체 타입을 만들 때 쓰이며, 다음과 같이 정의되어 있습니다:

type Omit<T, K extends keyof any> = Pick<T, Exclude<keyof T, K>>;
 

이 정의는 Pick과 Exclude를 조합하여 keyof T에서 지정한 키 K를 제거한 속성만 선택해 새로운 타입을 만듭니다.

 

그렇다면 Pick 없이 Exclude만으로 Omit을 직접 구현해 보면 어떻게 될까요? 

갑자기 Pick을 없앤다니, 이상할 수 있지만(ㅎㅎ;) 제가 처음에 Omit을 직접 구현했을 때는 아래와 같이 구성했습니다. 

type MyOmit<T, K extends keyof T> = { [P in Exclude<keyof T, K>]: T[P] };

 

위 타입은 대부분의 오브젝트에서 잘 통과가 됐지만,  readonly나 optional과 같은 modifier 속성이 유지되지 않았습니다. 

type User = {
  readonly name: string;
  title?: string;
}

type A = MyOmit<User, 'title'>;
// expect: { readonly name: string }
// result: { name: string }

 

 

왜 modifier가 사라질까?

Pick과 Exclude로 구성된 TS의 Omit은 잘 구현이 되는데 왜 제가 한 방식은 속성들이 보존이 안될까요? 

궁금해서 찾아봤지만 공식문서에는 아무리 봐도 안 나와있어서 이유를 찾는데 애 먹었습니다..

 

제가 참고한 자료들에서는 Homomoerphic(동형), Non-Homomorphic(비동형)라는 개념이 등장하는데요.

 

우선 제가 MappedType을 구성할 때 사용한 Exclude <keyof T, K>가 단순히 유니언 타입으로 키를 반환하기 때문에, 타입스크립트는 이를 Non-Homomorphic Mapped Type (비동형)으로 인식하고, 이 경우 원본 타입의 modifier는 보존되지 않습니다.

 

그럼, 여기서 나오는 개념인 Homomoerphic(동형), Non-Homomorphic(비동형) 이란 뭘까요?

 

용어가 조금 어려워서 제가 이해한 부분을 바탕으로 쉽게 말씀드리면

 

Homomoerphic(동형) 은 맵드타입(Mapped type)으로 키를 정의할 때  원본 객체의 키를 직접 참조하는 것이고 이 경우에는 readonly나 optional(?) 같은 modifier를 그대로 유지합니다.

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

 

반대로 제가 처음 했던 방식처럼 keyof를 가공해서 사용할 경우 컴파일러는 T와의 관계를 추적하지 못해 modifier가 보존되지 않는 Non-Homomorphic(비동형)으로 분류됩니다.

type NonHom<T, K> = {
  [P in Exclude<keyof T, K>]: T[P];
};

 

Homomoerphic(동형), Non-Homomorphic(비동형) 개념에 관련해서 반영된 PR, stack of flow 토론 등을 첨부할 테니 더 궁금하신 분은 링크를 참고해 주시면 감사하겠습니다.

 

결국 문제는 keyof T를 그대로 쓰지 않고 Exclude <keyof T, K>로 바꾼 데 있었습니다. 그렇다면 어떻게 해결할 수 있을까요?

 

as 사용해서 remapping 하기

바로 as 키워드를 사용하는 방식입니다. 매핑 중인 키에 조건을 걸거나 이름을 바꿀 수 있게 해 줍니다. 

type MyOmit<T, K extends keyof any> = {
  [P in keyof T as P extends K ? never : P]: T[P];
};

// use Exclude!
type MyOmit<T, K extends keyof T> = { 
  [P in keyof T as Exclude<keyof T, K>]: T[P] 
 };

이 방식은 keyof T를 유지하면서 as를 통해 필터링을 조건부로 수행합니다. 따라서 homomorphic(동형) 특성을 유지하며,

readonly, optional 같은 modifier도 정확히 보존이 됩니다.

 

참고로 Mapped type에서 as를 사용한 방식은 조건부가 아닌 키 이름을 변형하는 데도 활용됩니다.  공식문서 예제: 
type Getters<Type> = {
    [Property in keyof Type as `get${Capitalize<string & Property>}`]: () => Type[Property]
};
 
interface Person {
    name: string;
    age: number;
    location: string;
}
 
type LazyPerson = Getters<Person>;
// result
type LazyPerson = {
    getName: () => string;
    getAge: () => number;
    getLocation: () => string;
}

 

 


 

타입스크립트를 쓰다 보면, 처음엔 정말 편한데 어느 순간부터 "이걸 이렇게까지 복잡하게 써야 하나?" 싶은 지점들이 생깁니다.

 

저도 처음 개발을 시작할 때는 단순한 props 정의 정도에서 시작했지만, 점점 더 복잡한 요구사항들을 마주하면서 "타입으로도 이렇게까지 표현할 수 있구나" 하는 놀라움을 많이 느꼈습니다. 물론 그만큼 어려움도 있었고, 도대체 왜 이렇게 동작하는지 이해 안 되어 애먹은 시간도 많았지만요. 물론 지금도 현재진행형입니다 :) 

 

하지만 그 과정을 거치면서 몇몇의 특성과 테크닉들을 알게되었고, 또 그걸 활용해서 타입을 더 탄탄하게 만드는 능력치도 더 올라갔다고 생각합니다. 복잡한 타입으로 구성된 라이브러리의 타입을 살펴 볼 때 도움이 많이 되었고요.

 

특히 저한테 많이 도움이 되었던 사이트인 type-challenges에서 요상한 문제들을 풀면서 단순한 문법을 넘어서 타입스크립트를 "도구"로 활용하는 감각을 조금씩 익히게 된 것 같습니다.

 

이 글에 담은 내용들이 어떤 분에게는 너무 쉽고 당연한 내용, 또 어떤 분에게는 다소 낯선 내용일 수 있지만, 낯선 분들에게는 조금이라도 "아 이런 것도 가능하구나" 싶은 인사이트를 얻으셨다면 그걸로 충분하다고 생각합니다.

 

다음에 또 찾아오겠습니다. 감사합니다.

 


Reference:

 

 

반응형