(번역) 타입스크립트에 대한 다른 접근법

sehyun hwang·2024년 8월 12일
41
post-thumbnail

원문 : https://www.rob.directory/blog/a-different-way-to-think-about-typescript

타입 -> 집합

타입스크립트의 타입 시스템은 타입에 대해 동작하는 순수 함수형 언어로 생각할 수 있습니다. 그런데 타입에 대해 동작한다는 것은 어떤 의미일까요? 타입을 해당 타입에 할당할 수 있는 항목의 집합으로 해석하는 것이 저에 매우 유용했습니다. 이 집합에는 해당 타입에 할당될 수 있는 모든 실제 값이 포함됩니다.

이렇게 보면 일반적인 프로그래밍 언어에서 실제 집합을 연산하는 것과 같이, 타입스크립트의 핵심 구문도 주어진 집합의 항목을 연산하는 기능이라고 할 수 있습니다.

타입스크립트는 명목적 타입 시스템이 아닌 구조적 타입 시스템이기 때문에, 타입이 구성하는 이 "집합"은 실제 타입 정의 자체보다 훨씬 유용할 수 있습니다(항상 그런 건 아닙니다).

각각의 타입을 리터럴(실제 값) 집합으로 생각한다면, string을 단지 모든 문자 순열의 무한 집합으로 생각하거나 number를 모든 숫자 순열의 무한 집합으로 생각할 수 있습니다.

string and number set

타입 시스템을 집합 처리에만 사용되는 적절한 함수형 프로그래밍 언어로 생각한다면 고급 기능들을 좀 더 쉽게 이해할 수 있을 것입니다.

이 글에서는 타입스크립트의 대부분의 기능을 타입은 생성할 수 있는 집합이며, 타입스크립트는 집합을 기반으로 동작하는 함수형 프로그래밍 언어라는 관점에서 설명할 것입니다.

주의: 저는 집합과 타입이 동일하다고 말하는 게 아닙니다. 이 둘은 같지 않습니다.

타입스크립트 기본 타입 톺아보기

인터섹션 (&)

인터섹션(&)은 이 멘탈 모델을 통해 연산을 더 잘 추론할 수 있는 좋은 예시입니다. 다음의 예제를 보겠습니다.

type Bar = { x: number };
type Baz = { y: number };
type Foo = Bar & Baz;

Bar와 Baz를 인터섹팅하고 있습니다. 아마 처음에는 인터섹션 연산이 다음과 같은 식으로 적용될 거라고 생각할 수 있습니다.

intersecting Bar and Baz (no overlap)

두 객체 사이에 겹치는 부분을 식별하고 결괏값을 가져옵니다. 그런데... 겹치는 부분이 없네요? 좌측(LHS)에는 x만 있고 우측(RHS)에는 y만 있는데, 그럼에도 x와 y는 모두 숫자 타입입니다. 그러면 인터섹션 결과로 다음과 같은 타입이 허용되는 이유는 뭘까요?

let x: Foo = { x: 2, y: 2 };

상황을 쉽게 파악하려면 BarBaz 타입을 텍스트 그 자체보다, 이들이 구성하는 집합의 관점에서 생각해 보면 됩니다.

{ y: number } 타입을 정의해 보면, 최소한 y 속성을 갖고 있고 y는 숫자인 객체 리터럴의 무한 집합을 구성하게 됩니다.

intersection with set

주의: "최소한 y 속성을 갖고 있는 객체 타입의 집합"이라고 말한 것에 주목하세요. 이게 바로 몇몇 객체 타입에 y 이외의 속성이 존재하는 이유입니다. 변수가 { y: number } 타입을 갖는다면, 객체 내에 y 이외의 속성이 있는 것은 문제 되지 않고 타입스크립트도 이를 허용합니다.

이제 타입을 집합으로 대체하는 법을 알게되니 인터섹션이 더 잘 이해가 되네요.

유니온

이전에 구축한 멘탈 모델을 사용하여, 새로운 집합을 도출하기 위해 두 집합에 유니온을 적용했습니다.

type Foo = { x: number };
type Baz = { y: number };
type Bar = Foo | Baz;

union with set

타입 인트로스펙션 (introspection)

타입스크립트 메인테이너는 집합을 조작할 수 있도록 하는 게 편리할 것이라고 생각하여, 관련 기본 기능을 타입스크립트 내에 도입했습니다. 예를 들어, extends 키워드를 사용하여 한 집합이 다른 집합의 부분 집합인지를 확인해서 참/거짓 각각의 경우에 대해 새로운 집합을 반환할 수 있습니다.

type IntrospectFoo = number | null | string extends number
  ? "number | null | string constructs a set that is a subset of number"
  : "number | null | string constructs a set that is not a subset of number";

// IntrospectFoo = "number | null | string is not a subset of number"

extends 키워드는 LHS 집합이 RHS 집합의 부분 집합인지 확인합니다.

임의로 중첩할 수 있기 때문에 상당히 강력한 기능입니다.

type Foo = null
type IntrospectFoo = Foo extends number | null
  ? Foo extends null
    ? "Foo constructs a set that is a subset of null"
    : "Foo constructs a set that of number"
  : "Foo constructs a set that is not a subset of number | null";

// Result = "Foo constructs a set that is a subset of null"

하지만 타입 매개 변수를 사용하고, 타입 인자로 유니온 타입을 넘겨줄 때 상황은 복잡해집니다. 타입 매개 변수를 사용하면, 타입스크립트는 유니온을 집합으로 먼저 구성하는 것과 다르게 유니온의 모든 멤버가 각각 부분 집합을 만족하는지 결정을 내리는 작업을 수행합니다.

그래서 이전 예제를 타입 매개 변수를 사용하도록 살짝 바꿔보겠습니다.

type IntrospectT<T> = T extends number | null
  ? T extends null
    ? "T constructs a set that is a subset of null"
    : "T constructs a set that of number"
  : "T constructs a set that is not a subset of number | null";
type Result = IntrospectT<number | string>;

타입스크립트는 Result를 다음과 같이 변형합니다.

type Result = IntrospectT<number> | IntrospectT<string>;

그리고 Result 타입은 다음과 같이 결정됩니다.

"T constructs a set containing only number" | "T constructs a set with items not included in number | null";

이런 식으로 처리하는 게 단순히 대부분의 연산에서 편리하기 때문입니다. 그러나 튜플 구문을 사용하면 타입스크립트가 이런 식으로 동작하지 못하도록 할 수 있습니다.

type IntrospectFoo<T> = [T] extends [number | null]
  ? T extends null
    ? "T constructs a set that is a subset of null"
    : "T constructs a set that of number"
  : "T constructs a set that is not a subset of number | null";
type Result = IntrospectFoo<number | string>;
// Result = "T constructs a set that is not a subset of number | null"

조건부 타입을 더 이상 유니온이 아니라 내부에 유니온이 있는 튜플에 적용하고 있기 때문입니다.

이 예외 사항이 중요한 이유는 항상 타입을 즉시 구성하는 집합으로 생각하는 멘탈 모델이 완벽하지 않다는 것을 보여주기 때문입니다.

타입 매핑

일반적인 프로그래밍 언어에서는 집합을 순회하여(어떤 식으로 순회 되든지 상관없이) 새로운 집합을 생성할 수 있습니다. 예를 들어, 파이썬에서 다음과 같이 튜플 집합을 평탄화할 수 있습니다.

nested_set = {(1,3,5,6),(1,2,3,8), (9,10,2,1)}
flattened_sed = {}
for tup in nested_set:
  for integer in tup:
    flattened_set.add(integer)

우리의 목표는 이를 타입스크립트 타입으로 하는 것입니다.

만약 Array<number>을 숫자를 포함하는 배열의 모든 순열 집합으로 간주한다면 다음과 같을 것입니다.

the set of all permutations of arrays containing numbers

각각의 항목에서 숫자를 선택해서 집합 내에 바로 위치시키기 위해 몇 가지 변형을 적용해 보겠습니다.

select the numbers out

타입스크립트에서는 명령형 구문을 사용하는 대신 선언적으로 이 작업을 수행할 수 있습니다. 아래의 예제를 보겠습니다.

type InsideArray<T> = T extends Array<infer R>
  ? R
  : "T is not a subset of Array<unknown>";
type TheNumberInside = InsideArray<Array<number>>;
// TheNumberInside = number

이 선언문은 다음의 동작을 수행합니다.

  • T가 집합Array<infer R>의 부분 집합인지 확인합니다. (R은 아직 존재하지 않아서 이를 any로 대체함)
    • 만약 그렇다면 T가 구성하는 집합 내의 각각의 배열마다, 모든 배열의 항목을 R'라는 새 집합에 배치합니다.
      • 어떤 타입이 R'를 구성할지 추론하여 해당 타입을 R 내에 배치합니다. R은 오직 참인 경우만 사용됩니다.
      • 최종 타입으로 R을 반환합니다.
    • 만약 그렇지 않다면 에러 메시지를 제공합니다.

주의: 위 설명은 infer의 구현 방법에 기반한 것이 아닙니다. 단지 집합 기반의 멘탈 모델에서 infer가 동작하는 방법을 추론한 것입니다.

이 과정을 시각적으로 표현하면 다음과 같습니다.

visualized process

이 멘탈 모델로 타입스크립트가 사용하는 infer의 의미를 이해할 수 있습니다. 우리가 생성한 R' 집합을 만드는 방법을 설명하는 타입을 자동으로 추론합니다.

타입 변형 - 매핑된 타입

방금까지 타입스크립트를 통해 집합이 어떤 것과 유사한지를 매우 정확하게 검사하고 이를 기반으로 매핑하는 방법을 설명했습니다. 그렇지만 타입에 의해 구성되는 집합 내의 각 항목이 어떻게 생겼는지 좀 더 자세히 표현할 수 있다면 유용할 것입니다. 집합을 더 잘 표현할 수 있다면, 우리가 원하는 무엇이든 만들 수 있습니다.

매핑된 타입은 이에 대한 좋은 예시입니다. 그리고 집합의 모든 항목을 매핑하여 객체 타입을 생성하는 식으로 초기 사용법이 간단합니다.

예를 들어 다음과 같습니다.

type OnlyBoolsAndNumbers = {
  [key: string]: boolean | number;
};

Mapped types

객체 타입을 다시 집합에 매핑하는 마지막 단계는 우리의 머릿속에서 이뤄집니다.

또한 문자열의 부분 집합에 대해서도 매핑할 수 있습니다.

type SetToMapOver = "string" | "bar";
type Foo = { [K in SetToMapOver]: K };

["string", "bar"] 집합을 매핑하여 객체 타입인 {string: "string", bar: "bar"}를 생성했습니다.

객체 타입의 키와 값에 대해 타입 수준의 임의의 계산을 수행할 수 있습니다.

type SetToMapOver = "string" | "bar";
type FirstChacter<T> = T extends `${infer R}${infer _}` ? R : never;
type Foo = {
  [K in SetToMapOver as `IM A ${FirstChacter<K>}`]: FirstChacter<K>;
};

주의: never은 공집합입니다. 집합 내에 아무런 값도 없습니다. 따라서 never 타입에는 절대로 어떠한 것도 할당할 수 없습니다.

이제 ["string", "bar"] 집합을 매핑하여 새로운 타입 {["IM A s"]: "s", ["IM A b"]: "b"}를 생성했습니다.

반복적인 로직

집합에 어떤 변형을 적용하고 싶지만, 이 변형을 표현하기가 까다로울 땐 어떻게 할까요? 다음 항목으로 넘어가기 전에 내부적인 계산을 임의의 횟수만큼 실행해야 합니다. 런타임 프로그래밍 언어에서는 간단하게 루프를 사용할 수 있습니다. 그러나 타입스크립트의 타입 시스템은 함수형 언어이기 때문에 재귀를 사용해야 합니다.

type FirstLetterUppercase<T extends string> =
  T extends `${infer R}${infer RestWord} ${infer RestSentence}`
    ? `${Uppercase<R>}${RestWord} ${FirstLetterUppercase<RestSentence>}` // 재귀 호출
    : T extends `${infer R}${infer RestWord}`
    ? `${Uppercase<R>}${RestWord}` // 기본 케이스
    : never;
type UppercaseResult = FirstLetterUppercase<"upper case me">
// UppercaseResult = "Upper Case Me"

처음에는 뭐지...? 싶고 이상해 보일 수도 있지만 실제로는 코드양이 많을 뿐 복잡하지 않습니다. 이제 동일한 기능을 타입스크립트 런타임 버전으로 작성하여 무슨 일이 일어나고 있는지 확인해 보겠습니다.

const separateFirstWord = (t: string) => {
  const [firstWord, ...restWords] = t.split(" ");
  return [firstWord, restWords.join(" ")];
};
const firstLetterUppercase = (t: string): string => {
  if (t.length === 0) {
    // 기본 케이스
    return "";
  }
  const [firstWord, restWords] = separateFirstWord(t);
  return `${firstWord[0].toUpperCase()}${firstWord.slice(1)} ${firstLetterUppercase(restWords)}`; // 재귀 호출
};

현재 문장의 첫 번째 단어를 얻어서, 첫 글자를 대문자로 바꾸고 나머지 단어에도 동일하게 적용한 다음, 이를 이어 붙입니다.

런타임 예제와 타입 수준의 예제를 비교해 보겠습니다.

  • 기본 케이스를 생성하는 if 문은 if 부분 집합 검사 (extends)로 대체됩니다.
    • 이는 if 문과 매우 비슷합니다. 왜냐하면 infer을 통해 만들어진 각각의 집합들(R, RestWord, RestSentence)은 단일 문자열 리터럴만 포함하기 때문입니다.
  • 구조 분해를 사용하여 문장을 첫 번째 단어와 그 이외 나머지로 분리한 것은 infer 매핑에 의한 세 개의 집합 ${infer R}${infer RestWord} ${infer RestSentence}으로 대체됩니다.
  • 함수 매개 변수는 타입 매개 변수로 대체됩니다.
  • 재귀적 함수 호출은 재귀적 타입 인스턴스화로 대체됩니다.

이러한 방식으로 모든 계산을 설명할 수 있습니다 (타입 시스템은 튜링 완전 (turing complete)합니다).

결론

만약 타입스크립트를 집합을 기반으로 동작하는 방식으로 설명할 수 있고, 이러한 집합을 사용하여 엄격한 컴파일 타임 검사를 수행할 수 있다면, (아직은 아니더라도) 고급 타입스크립트 기능에도 익숙해질 것입니다. 이를 통해 더 많은 버그를 미리 발견할 수 있게 됩니다.

이 멘탈 모델은 완벽하지 않지만 타입스크립트의 일부 고급 기능을 포함하여 여러 가지 기능에 꽤 잘 적용됩니다.

3개의 댓글

comment-user-thumbnail
2024년 8월 14일

덕분에 간만에 벨로그에서 너무 좋은 글을 읽었습니다. 감사합니다.

답글 달기
comment-user-thumbnail
2024년 8월 14일

진짜 맛있는 글이었습니다

답글 달기
comment-user-thumbnail
2024년 8월 17일

https://gogoanimeat.com können Sie eine umfangreiche Auswahl an Anime genießen, einschließlich klassischer und neuer Veröffentlichungen. Die Plattform bietet eine benutzerfreundliche Oberfläche und schnelle Updates.

답글 달기