unknown
, never
, union
, intersect
와 같은 것들은 쓸 줄은 아는데 응용하려면 헷갈리는 대표적인 타입스크립트 문법입니다.
이번 글에서는 타입스크립트가 어떻게 집합으로 타입을 정의하는지 정확한 논리를 알아보겠습니다.
TypeScript 의 기본적인 논리는 집합의 포함관계입니다. |
, &
, unknown
, never
등 typescript 의 기본 부품들이 모두 집합에 기반합니다. (얘들이 뭔지는 좀 이따 알아보겠습니다 🙌)
보통 "이 값이 이 타입인가" 라는 표현을 하곤 하는데, 타입스크립트에서는 "이 값이 이 집합(타입)의 원소인가" 라고 질문하면 정확합니다. 가령 1
은 number
집합의 원소이다 와 같이 말이죠.
진짜 코드를 보기 전에 의사코드로 먼저 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
에 숫자를 넣어주면 문자열을 반환한다는 뜻이니, foo
는 numberToString
집합의 원소가 맞습니다.
같은 논리에서 집합을 뒤집은 아래 예시를 보겠습니다.
type numberOrBooleanToString = (모든 숫자의 집합 U 모든 불리언의 집합) => (모든 문자열의 집합);
const boo: numberOrBooleanToString = (모든 숫자의 집합) => (모든 문자열의 집합); // ❌ ERROR: 이 함수는 위 집합의 원소가 아닙니다
boo
는 숫자를 받아서 문자열을 리턴하는 함수입니다. 하지만 numberOrBooleanToString
집합이 원하는 요건은 불리언을 넣어줘도 문자열을 리턴하는 것입니다. 타입 정의상 boo
는 그럴 능력은 없기 때문에, boo
는 numberOrBooleanToString
의 원소가 아닙니다.
의사코드만 보다 보니 감이 안 잡히네요. 진짜 타입스크립트 문법으로 넘어가 보겠습니다.
정적 언어에서는 변수를 선언할 때 타입을 정의하지만, 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 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 ?