TypeScript(2): 타입은 집합이다

우현민·2022년 11월 1일
2

TypeScript

목록 보기
2/2
post-thumbnail

unknown, never, union, intersect 와 같은 것들은 쓸 줄은 아는데 응용하려면 헷갈리는 대표적인 타입스크립트 문법입니다.
이번 글에서는 타입스크립트가 어떻게 집합으로 타입을 정의하는지 정확한 논리를 알아보겠습니다.

집합으로 타입을 정의한다

TypeScript 의 기본적인 논리는 집합의 포함관계입니다. |, &, unknown, never 등 typescript 의 기본 부품들이 모두 집합에 기반합니다. (얘들이 뭔지는 좀 이따 알아보겠습니다 🙌)

보통 "이 값이 이 타입인가" 라는 표현을 하곤 하는데, 타입스크립트에서는 "이 값이 이 집합(타입)의 원소인가" 라고 질문하면 정확합니다. 가령 1number 집합의 원소이다 와 같이 말이죠.

진짜 코드를 보기 전에 의사코드로 먼저 TypeScript가 추구하는 정적 타입 검사의 철학을 알아보겠습니다. 어느 정도 타입스크립트 문법이 가미된 의사코드인지라, 타입스크립트 syntax 에 익숙하지 않으시다면 세부 문법은 그냥 "그런 게 있나 보다~" 정도로 넘겨 주시면 되겠습니다!


먼저 변수 할당입니다.

const a: (모든 숫자의 집합) = "asdf"; // ❌ ERROR: "asdf" 는 {모든 숫자의 집합}의 원소가 아닙니다.

const b: (모든 문자열의 집합) = "qwer"; // ✅ OK: "qwer" 은 {모든 문자열의 집합}의 원소입니다.

const c: (모든 숫자의 집합 U 모든 문자열의 집합) = "zxcv"; // ✅ OK: "zxcv" 는 {모든 숫자의 집합과 모든 문자열의 집합의 합집합}의 원소입니다

const d: ("a" 로 시작하는 모든 문자열의 집합) = "qewr"; // ❌ ERROR: "qwer" 은 {"a"로 시작하는 모든 문자열의 집합}의 원소가 아닙니다

const e: (키값이 string 이고 value 는 number인 모든 객체의 집합) = { a: 1, b: 2 }; // ✅

함수에 넘기는 파라미터도 동일하게 해석할 수 있습니다. 가령 모든 숫자의 집합의 원소를 받아서 모든 문자열의 집합의 원소를 리턴하는 함수의 타입인 numberToString 을 보겠습니다.

type numberToString = (모든 숫자의 집합) => (모든 문자열의 집합);

numberToString('1'); // ❌ ERROR: '1' 은 {모든 숫자의 집합} 의 원소가 아닙니다

numberToString(2); // ✅ OK: 2 는 {모든 숫자의 집합} 의 원소입니다.

사실 타입스크립트는 자바스크립트에서 "값"이라고 부를 수 있는 모든 것의 타입(집합)을 정의할 수 있습니다. 가령 변수뿐 아니라 함수에도 타입을 달아서, 동일하게 집합의 포함관계로 해석합니다.

type numberToString = (모든 숫자의 집합) => (모든 문자열의 집합);

const foo: numberToString = (모든 숫자의 집합 U 모든 불리언의 집합) => (모든 문자열의 집합); // ✅ OK: 이 함수는 위 집합의 원소입니다

함수는 포함관계가 머리속에서 한번에 떠오르기 쉽지 않습니다. 위 예시에서, numberToString숫자를 받으면 문자열을 반환할 수 있는 모든 함수의 집합입니다. foo숫자 또는 불리언을 받아서 문자열을 반환하는 함수입니다. 그러니까, foo 에 숫자를 넣어주면 문자열을 반환한다는 뜻이니, foonumberToString 집합의 원소가 맞습니다.

같은 논리에서 집합을 뒤집은 아래 예시를 보겠습니다.

type numberOrBooleanToString = (모든 숫자의 집합 U 모든 불리언의 집합) => (모든 문자열의 집합);

const boo: numberOrBooleanToString = (모든 숫자의 집합) => (모든 문자열의 집합); // ❌ ERROR: 이 함수는 위 집합의 원소가 아닙니다

boo는 숫자를 받아서 문자열을 리턴하는 함수입니다. 하지만 numberOrBooleanToString 집합이 원하는 요건은 불리언을 넣어줘도 문자열을 리턴하는 것입니다. 타입 정의상 boo 는 그럴 능력은 없기 때문에, boonumberOrBooleanToString의 원소가 아닙니다.


의사코드만 보다 보니 감이 안 잡히네요. 진짜 타입스크립트 문법으로 넘어가 보겠습니다.



TypeScript 문법

정적 언어에서는 변수를 선언할 때 타입을 정의하지만, TypeScript는 꼭 그러진 않습니다. 최대한 JavaScript 의 훌륭한 구조를 살리되, 집합의 포함관계를 이용해서: 특정 값이 해당 집합의 원소가 될 수 있는지를 계속 고민해주는 친절한 도구입니다.

기본 타입

위의 집합 설명에 맞춰서 typescript가 지원하는 간단한 타입들을 정의해 보겠습니다.

  • number: 모든 숫자의 집합. 1, 1.2, NaN 등등
  • string: 모든 문자열의 집합. 'asfd', '' 등등
  • boolean: 모든 불리언의 집합. true, false 가 있다.

꼭 집합이 여러 원소를 가져야 하는 건 아닙니다.

  • 1: 모든 1 의 집합. (즉 1 만 원소로 가지는 집합)

코드를 볼까요?

const a: number= "asdf"; // ❌ ERROR: "asdf" 는 number (모든 숫자의 집합)의 원소가 아닙니다.

const b: string = "qwer"; // ✅ OK: "qwer" 은 string (모든 숫자의 집합)의 원소입니다.

const c: 2 = 1; // ❌ ERROR: 1 은 {2} 의 원소가 아닙니다.

함수 타입

함수의 타입도 꽤나 직관적으로 이해할 수 있습니다. 가령 아래 함수의 타입은 아래와 같습니다.

  • 첫 번째 인자로 number 집합의 원소를 받아
  • string 집합의 원소를 반환
  • 하는 함수
const numberToString = (n: number): string => {
  return `${n}`;
};

아니면, 함수 자체를 타입화하기도 합니다. 아래 함수의 타입은 아래와 같습니다.

  • {첫 번째 인자로 number 집합의 원소를 받아 string 집합의 원소를 반환하는 함수의 집합} 의 원소
const numberToString: (n: number) => string = (n) => {
  return `${n}`;
};

부록:Type Alias

슬슬 타입이 복잡하다고 느껴집니다. 우리가 값을 변수에 할당하여 별명을 주듯, 타입도 변수에 할당하여 별명을 줄 수 있습니다.

type A = number;
const a: A = "asdf"; // ❌ ERROR: "asdf" 는 number (모든 숫자의 집합)의 원소가 아닙니다.

type B = string;
const b: B = "qwer"; // ✅ OK: "qwer" 은 string (모든 숫자의 집합)의 원소입니다.

type C = 2;
const c: C = 1; // ❌ ERROR: 1 은 {2} 의 원소가 아닙니다.

type 을 변수 바로 윗줄에 선언했지만, 꼭 그래야 하는 건 아닙니다. 정말 우리가 아는 변수들처럼 사용하면 됩니다.

가독성을 위해, 이후로는 type alias 를 활용하겠습니다.


객체 타입

객체 자체를 타입화하고 싶을 때도 많습니다. 가령 아래의 Person 타입은:
name 이라는 키에 string 집합의 원소가 있고, age 라는 키에 number 집합의 원소가 있는 모든 객체
의 집합입니다.

type Person = { name: string, age: number }
const value = { name: 'woohm402', age: 22 };
const person: Person = value; // ✅

따라서 아래의 경우도 가능해지겠죠? value2 도 해당 집합의 조건에 부합하므로, Person 집합의 원소니까요.

type Person = { name: string, age: number }
const value2 = { name: 'woohm402', age: 22, anyKey: 1234 };
const person2: Person = value2; // ✅

아래와 같은 행동을 하면 타입스크립트가 오류를 띄웁니다.

type Person = { name: string, age: number }
const person3: Person = { name: 'woohm402', age: 22 }; // ✅
console.log(person3.height); // ❌ ERROR: Person 집합의 모든 원소에 `height` 라는 키값이 있다고 보장할 수 없음

const person: Person = { name: 'woohm402', age: 22, anyKey: 1234 } 와 같이 바로 값을 할당하면, 오타 방지 등을 위해 타입스크립트가 에러로 처리하여 Object literal may only specify known properties 오류를 반환합니다. (더 자세한 개념은 freshness 를 참고해 주세요) 때문에 위 예제에서는 object literal 을 바로 할당하지 않기 위해 value 라는 변수에 한번 값을 담은 다음 다시 할당합니다.


합집합과 교집합

TypeScript 를 해보신 분들이라면, 왜 이렇게 제가 집합 이라는 것에 집착하는지 궁금해하실 거예요. 사실 "1 의 타입은 number 니까 오류 없이 할당된다" 라고 설명해도 충분히 이해되거든요. 하지만 그렇게 설명하면, |& 를 이해하기 쉽지 않습니다.

이번에는 합집합을 알아보겠습니다. 우리가 선언하는 변수가 문자열도 받을 수 있고, 숫자도 받을 수 있어야 한다고 해 볼게요. javascript 를 사용하다 보면 그런 상황이 많이 나오잖아요?

const a: string 또는 number = 1;

다시, : 가 하는 역할에 맞춰 생각해볼게요. 우리는 다음 문장을 완성해야 합니다.

a는 { 무슨 집합 } 의 원소이다.

정답을 말해보면, a 는 (string 집합과 number 집합의 합집합) 의 원소입니다.

const a: string | number = 1; // | 는 합집합 기호입니다.

비슷하게, & 는 교집합을 나타내는 기호입니다. 교집합이 주로 사용되는 경우는 두 객체의 키값이 모두 있는 타입을 만들고 싶을 때입니다.

가령 아래와 같이 말이죠.

type Human = { name: string; IQ: number };
type Horse = { name: string; speed: number; };
type Centaurus = Human & Horse; // { name: string; IQ: number; speed: number }

const c: Centaurus = { name: 'woohm402', IQ: 200, speed: 10000 };

단순히 생각했을때, 교집합이니까 Human & Horse{ name: string } 이어야 하지 않나? 라고 오해할 수 있습니다만, 그렇지 않습니다.

Human 은 { name이 string 이고 IQ 는 number 인 모든 객체의 집합 } 이고 Horse 는 { name 이 string 이고 speed 가 number 인 모든 객체의 집합 } 이니까요.

반대로 Human | Horse{ name: string } 입니다. (엄밀히는 조금 다르지만)

객체 타입은 집합의 조건이지, 집합 그 자체가 아닙니다.

경험상 교집합은 합집합만큼 자주 사용되진 않습니다.


특수 타입: unknown, never, any

이들에 대한 내용도, 집합 빌드업을 한 큰 이유입니다.

타입스크립트를 써본 적이 있으시다면 이들이 무슨 역할을 하는지, 대충 언제 사용하는지 익숙하실 거예요.
과거의 제가 이해하기로는: unknown 은 뭔지 모를때 쓰고, never 은 언제 쓰는지 모르겠고, any 쓰는 사람은 가서 뒤통수를 때리면 된다 였습니다.

집합의 기준에 맞추어 다시 말하면,

  • unknown: 전체집합
  • never: 공집합
  • any: 어떤 집합에 대해서도 상위집합도 부분집합도 될 수 있는 조커 🤡

입니다. (저는 이 사실을 처음 알고 큰 충격을 받았습니다.)

따라서 unknown 은 어떤 타입과 | (합집합) 해도 unknown 이고, never 은 어떤 타입과 & (교집합) 해도 never입니다.

unknown 은 전체집합이므로, 모든 값을 받을 수 있어야 할 때 사용합니다.

const convertToString = (org: unknown): string => {
  console.log(org.name); // ❌ ERROR: 전체집합의 모든 원소에 대해 name 이라는 필드가 있다고 보장할 수 없습니다.
  
  return /* ... */
};

const str1 = convertToString({ a: 1, b: 2 }); // ✅ OK: 이 원소는 전체집합의 원소입니다.
const str2 = convertToString(null); // ✅ OK: 이 원소도 전체집합의 원소입니다.

never 은 공집합이므로, 어떤 값도 할당하고 싶지 않을 때 사용합니다.

type ThisTypeMustNotHaveNameProperty = { name: never };
const t: ThisTypeMustNotHaveNameProperty = {};
t.name = 1; // ❌ ERROR: 1 은 공집합의 원소가 아닙니다.

...네, 억지스러운 상황이 맞습니다.
사실 never 은 이런 코드레벨의 상황보다는, 제네릭 같은 걸 들고 춤을 추는 복잡한 타입을 만들 때 유용하게 사용됩니다.


이번 글에서는 이렇게 타입스크립트의 타입 시스템이 어떻게 집합을 표현하고 검사하는지 알아봤습니다. 이 글의 내용을 wrap-up 하는 간단한 퀴즈로 글을 마칩니다.

type Foo = { a: number } | ({ a: null, b: string } & unknown);
const value = { a: 1, b: 'woohm402' }
const foo: Foo = value; // pass or fail ?
profile
프론트엔드 개발자입니다

0개의 댓글