본문 바로가기

FrontEnd

[번역] 집합론으로 이해하는 타입스크립트

반응형

집합론을 이용해 타입스크립트를 좀 더 잘 이해해 봅시다.

아래 글의 번역입니다.

https://blog.thoughtspile.tech/2023/01/23/typescript-sets/

 

Making sense of TypeScript using set theory

I've been working with TypeScript for a long long time. I think I'm not too bad at it. However, to my despair, some low-level behaviors still confuse me: Why does 0 | 1 extends 0 ? true : false evaluate to false?I'm very ashamed, but I sometimes confuse "s

blog.thoughtspile.tech

오랫동안 TypeScript로 작업해 왔습니다.
저는 타입스크립트를 꽤 잘 쓴다고 생각합니다만,

실망스럽게도 몇 가지 저수준 동작을 여전히 이해하지 못하고 있었습니다.

  • 0 | 1 extends 0 ? true : false는 왜 false인가요?
  • 부끄럽지만 가끔 "서브타입"과 "슈퍼타입"을 혼동하기도 합니다.
  • 타입 축소(narrowing) 및 확장(widening)은 뭐고 이게 슈퍼/서브타입과 무슨 상관이 있죠?
  • { name: string } 및 { age: number }를 모두 만족하는 객체를 원하는 경우 &를 쓰는게 맞나요? |를 쓰는게 맞나요?
    • 기능면에선 합치는(|)게 맞는것 같은데
    • 해당 객체가 두 인터페이스를 동시(&)에 만족시켰으면 좋겠어요
  • anyunknown은 어떤 차이가 있죠?
    • 제가 배운 것은 "Avoid Any, Use Unknown"과 같은 부정확한 암기법 입니다. 이유가 뭔가요?
  • never는 정확히 뭐죠? "절대 발생하지 않는 값"은 이해가 안됩니다.
  • whatever | never === whatever , whatever & never === never. ??
  • 도대체 왜 const x: {} = true는 유효한 걸까요?
never에 대해 조사를 하다가 Zhenghao He의 Complete Guide To TypeScript’s Never Type를 우연히 발견했습니다.
(그의 블로그를 확인하세요. 정말 멋집니다!).
타입은 단지 값의 집합일 뿐이며 — 붐 — 뭔가 깨달음을 얻은 것 같습니다.
기본으로 돌아가 TS에 대해 알고 있는 모든 것을 집합론 용어로 재구성해 봅시다.
typescript type set

집합론

집합은 정렬되지 않은 객체 컬렉션 입니다.

쉽게 설명하기 위해,

두 개의 사과(ivan과 bob이라고 해봅시다), 일명 객체와

사과를 넣을 수 있는 가방,즉 일명 집합이 있다고 가정해 보겠습니다.

 

총 4개의 사과 집합을 만들 수 있습니다.

  • 사과 ivan이 든 가방, { ivan }
    • 집합은 내부에 집합 아이템이 있는 중괄호로 표시됩니다.
  • 마찬가지로 가방에 사과 밥 { bob }을 넣을 수 있습니다.
  • 두 개의 사과가 든 가방, { ivan, bob }.
    • 이것은 전체 집합(Universe)이라 불립니다. 왜냐하면 현재 우리 세상에는 이 두 개의 사과 외에는 아무것도 없기 때문입니다.
  • 빈 가방, 일명 공집합(empty set), {}. 이것은 특별한 기호 ∅로 표현합니다.

집합은 종종 "벤 다이어그램(venn diagrams)"으로 그려지며
각 집합은 원으로 표시됩니다.

venn diagrams

모든 항목을 나열하는 것 외에도 조건별로 집합을 만들 수도 있습니다.

ivan은 빨간색이고 bob은 녹색 사과라 한다면,
"R은 빨간 사과 집합"이 { ivan }을 의미한다고 할 수 있습니다.

 

집합 A는 A의 모든 요소가 B에도 있는 경우 집합 B의 서브셋입니다.
사과 세계에서 { ivan }은 { ivan, bob }의 서브셋이지만 { bob }는 { ivan }의 서브셋이 아닙니다.
모든 집합은 자신의 서브셋이고 {}는 다른 집합 S의 서브셋입니다.
{}의 어떤 항목도 S에서 누락되지 않았기 때문입니다.
 
집합에 정의된 몇 가지 유용한 연산자가 있습니다.
  • Union C = A ∪ B는 A 또는 B에 있는 모든 요소를 ​​포함합니다.
    • A ∪ ∅ = A 입니다.
  • Intersection  : C = A ∩ B는 A와 B에 모두 존재하는 모든 요소를 포함합니다.
    • A ∩ ∅ = ∅ 를 주의하세요.
  • Difference C = A \ B는 A에는 있지만 B에는 없는 모든 요소를 ​​포함합니다.
    • A \ ∅ = A 입니다.

이제 지금까지 배운 내용이 타입에 어떻게 매핑되는지 살펴보겠습니다.


집합론이 타입과 어떤 관계가 있나요?

중요한 점은 "타입"을 JavaScript 값 집합으로 생각할 수 있다는 것입니다.

  • 전체 집합은 JS 프로그램이 생성할 수 있는 모든 값입니다.
  • 타입(타입스크립트 타입이 아니라 일반적인 타입)은 JS 값의 집합입니다.
  • 일부 타입은 TS로 표시될 수 있지만 몇몇 타입은 아닙니다.
    • "0이 아닌 숫자"와 같이 타입스크립트로 표현할 수 없는 타입이 있습니다.
  • A extends B는 조건부 타입(conditional types)과 제네릭 제약조건(generic constraints)의 설명과 같이 "A는 B의 서브타입"으로 읽을 수 있습니다.
  • 유니온(|)과 인터섹션(&) 타입 연산자는 두 집합의 합집합과 교집합일 뿐입니다.
  • Exclude<A, B>는 A와 B가 모두 유니온 타입일 때만 작동한다는 점을 제외하면 TS의 difference 연산자라 생각하면 좋습니다.
  • never는 공집합 입니다.
    • 증명
      • A & never = never
      • A | never = 모든 타입 A에 대해 A
      • Exclude<0, 0> = never.
이러한 관점의 변화는 이미 몇 가지 유용한 통찰력을 제공합니다.
  • 타입 A의 서브타입은 타입 A의 서브셋입니다. 슈퍼타입은 슈퍼셋 입니다.
  • 타입 넓히기(widening)는 일부 추가 값을 허용하여 타입 집합을 더 넓게 만듭니다.
  • 타입을 좁히면(Narrowing) 특정 값이 제거됩니다. 기하학적 의미를 갖습니다.

Boolean types

가장 간단한 타입부터 시작해 봅시다.

  • 리터럴 타입은 각각 단일 값인 true 및 false입니다.
  • boolean은 부울 값입니다.
  • 공집합은 never 입니다.
Boolean types
타입 세계와 집합 세계 사이를 매핑해 봅시다.
  • boolean = true | false 입니다.
    • 실제로 타입스크립트가 이를 구현하는 방법입니다.
  • true는 boolean의 서브셋/서브타입 입니다.
  • never는 공집합이므로 true, false 및 boolean의 서브셋/서브타입 입니다.
  • &는 교집합입니다
    • false & true = never
    • boolean & true = (true | false) & true = true (전체집합인 boolean은 교집합의 항등원)
    • true & never = never
  • Exclude는 집합 difference를 정확하게 계산합니다: Exclude<boolean, true> -> false
    • 내부적으로 boolean이 정확하게 true|false로 구현되어 있기 때문입니다.
  • |는 유니온 입니다. 
    • true | never = true, boolean | true = boolean
      • (리터럴의 전체집합인 boolean은 다른 합집합 대상을 삼켜버립니다.)

이제 약간 까다로운 extends를 다루어 보겠습니다.

type A = boolean extends never ? 1 : 0;
type B = true extends boolean ? 1 : 0;
type C = never extends false ? 1 : 0;
type D = never extends never ? 1 : 0;
"extends"가 "is subset of"로 읽힐 수 있다는 것을 기억한다면 대답은 분명해야 합니다.
  • A는 0
  • B는 1
  • C는 1
  • D는 1
null 및 undefined는 각각 하나의 값만 포함하는 집합(타입)이라는 점을 제외하면 boolean과 같습니다.
  • never extend null은 여전히 ​​유효합니다.
  • null & boolean은 JS 값이 동시에 2개의 서로 다른 JS 타입이 될 수 없기 때문에 never입니다.
null 및 undefined는 각각 하나의 값만 포함하는 집합(타입)이라는 점을 제외하면 boolean과 같습니다.

Strings and other primitives

String은 "모든 JS String"의 타입이며 모든 문자열에는 해당 리터럴 타입이 있습니다.
그러나 불리언 같은 유한 집합과 한 가지 중요한 차이점이 있습니다.
가능한 문자열 값이 무한히 많다는 것입니다.
유한한 컴퓨터 메모리엔 유한한 문자열만 표현할 수 있기 때문에 거짓말일 수 있습니다만,

a) 모든 문자열을 열거하는 것은 실용적이지 않으며
b) 타입 시스템은 더러운 실제 제한에 대해 걱정하지 않고 순수한 추상화 위에서 동작할 수 있습니다.
 
집합과 마찬가지로 String 타입은 몇 가지 다른 방법으로 구성할 수 있습니다.
  • |(union)을 사용하면 유한한 String 집합을 구성할 수 있습니다.
    • type Country = 'de'|'us';
  • 무한한 값 목록을 작성할 수 없기 때문에 길이가 2보다 큰 모든 문자열과 같은 무한 집합은 표현할 수 없습니다.
  • 멋진 탬플릿 리터럴 타입(template literal types)을 이용하면 몇몇 무한 집합을 만들 수 있습니다.
    • type V = `v${string}`은 v로 시작하는 문자열 입니다.
리터럴 string 타입과 템플릿 리터럴 타입의 합집합과 교집합을 만들 수 있습니다.
union 타입을 템플릿 리터털 타입과 교집합할 때, TS는 템플릿에서 리터럴을 필터링할 만큼 똑똑합니다.
그래서 'a' | 'b' & `a${string}` = 'a' 입니다.
 
그러나 TS는 템플릿을 병합할 만큼 똑똑하지 않으므로 `a${string}` & `b${string}`은 never 입니다.
(물론, 분명히 string은 동시에 "a"와 "b"로 시작할 수 없습니다)
 
또한 몇몇 string 타입은 TS에서 전혀 표현할 수 없습니다.
'a'를 제외한 모든 문자열"을 Exclude<string, 'a'>로 표현할 수 있을 거라 생각할 수 있으나,
TS는 string을 가능한 모든 문자열 리터럴의 합집합으로 모델링하지 않기 때문에, 이는 다시 string로 표현됩니다.
템플릿 리터럴 타입 문법도 이 부정 조건을 표현할 수 없습니다. 아쉽습니다.
 
number, symbol 및 bigint에 대한 타입은 "템플릿" 타입 없이 유한 집합으로 제한된다는 점을 제외하면
String 타입(집합)과 동일한 방식으로 동작합니다.
integer, 0과 1 사이의 숫자 또는 양수와 같은 일부 number 서브타입을 사용할 수 없어서 유감입니다.
primitive types
지금까지 서로 간에 교집합이 없는 모든 프리미티브 JS/TS 타입을 다루었습니다.
이제 집합과 타입 사이의 매핑이 익숙해졌으며,
몇몇 타입은 TS에서 정의할 수 없다는 것을 알았습니다.

interface와 object 타입

const x: {} = 9가 왜 정상적일까요? 이를 이해하지 못하고 있다면, 이 문단은 여러분을 위한 것입니다.
TS 객체 타입/레코드/인터페이스에 대한 우리의 가정은 잘못된 멘탈 모델 위에 구축되어 있습니다.

먼저, type Sum9 = { sum: 9 } 와 같은 타입이
단일 객체 값 { sum: 9 }와 일치하는 객체에 대한 "리터럴 타입"처럼 동작할 것이라 생각할 수 있습니다.
 
이것은 전혀 타입의 동작 방식이 아닙니다.
Sum9 타입은 "9를 얻기 위해 적절한 sum에 액세스할 수 있는 것"을 의미합니다.
 
즉, 타입은 조건/제약(condition / constraint)과 비슷합니다.
즉 타입을 이용하면 TS가 알 수 없는 date 속성에 대해 불평하지 않고 객체 obj = { sum: 9, date: '2022-09-13' }를 인자로
(data: Sum9) => number를 호출할 수 있습니다.
 
따라서 {} 타입{} JS 리터럴 객체에 해당하는 "빈 객체" 타입이 아니라
어떤 속성의 엑세스에도 관심없는 타입을 의미합니다.
 
x = 9는 어떤 속성의 엑세스에도 관심없으므로 {}를 충족합니다.
const x: { toString(): string } = 9;와 같이 더 대담한 주장을 할 수도 있습니다.
x.toString()을 사용하면 실제로 문자열을 얻을 수 있기 때문입니다.
 
사실, 더 정확한 이유는
keyof number는 "toString" | "toFixed" | "toExponential" | "toPrecision" | "valueOf" | "toLocaleString"라는 사실을 통해 알 수 있습니다.
이것은 TS가 우리의 기본 타입을 비밀리에 객체로 본다는 것을 의미합니다. (autoboxing 때문입니다) .

https://www.typescriptlang.org/play?#code/MYewdgzgLgBAHgLhgbwL4wLwwJwG4BQokIANgKYB0JIA5gBRwVQgDKUATgJZj0CUvQA

반응형

null 및 undefined는 {}를 충족하지 않습니다. 속성을 읽으려고 하면 오류가 발생하기 때문입니다.

직관적이지는 않지만, 의미가 있습니다.

"| 또는 &" 문제로 돌아가면,
&와 |는 객체 모양을 대상으로 하는게 아니라, 값 집합을 대상으로 합니다.
따라서 name과 age를 모두 가진 객체 집합을 나타내려면
{ name: string } & { age: number }가 필요합니다. (and는 & 입니다.) 

이상한 object 타입은 뭘까요?
지금까지 배운 것처럼,
인터페이스의 모든 속성은 우리가 타입을 명시하는 prop에 대한 접근 가능 제약/조건을 추가할 뿐이기에,
primitive 타입을 필터링하는 인터페이스를 선언할 방법이 없습니다.
(ex : const x: {} = 9)
 
이것이 바로 TS에 특별히 "프리미티브가 아닌 JS 객체"를 의미하는 내장 object 타입이 있는 이유입니다.
object와 교차하여 인터페이스를 만족하는 primitive 값이 아닌 값만 가져올 수 있습니다.
이제 const x: object & { toString(): string } = 9는 실패합니다.

이제 모든 것을 타입 맵에 추가해 보겠습니다.

added type map

extends

TS의 extends 키워드는 혼란스러울 수 있습니다.
그것은 기능을 추가한다는 의미에서 클래스를 확장하는 객체 지향 세계에서 왔지만,
TS는 구조적 타이핑(structural typing)을 사용하기 때문에
type Extends<A, B> = A extends B ? true : false의 extends는 class X extends Y {}의 extends와 동일하지 않습니다.
 
대신 A extends B는 아래와 같이 읽을 수 있습니다.
  • A is a sub-type of B
  • A is a subset of B
B가 유니온 타입이면, A타입의 모든 멤버가 B에 있어야 합니다.
B가 "제약이 존재하는" 인터페이스인 경우
A는 B의 제약 조건을 위반하지 않아야 합니다.
(타입스크립트 extends는 class extends를 확장합니다.)
 
이 "서브셋" 관점으로 바라보기는 extends 피연산자의 순서를 섞지 않는 가장 좋은 방법입니다.
  • 0 | 1 extends 0은 false입니다.
    • 2개 요소 집합 {0, 1}은 1개 요소 {0}의 하위 집합이 아니기 때문입니다.
    • ({0,1}이 기하학적 의미에서 {1}을 확장하더라도).
  • never extends T는 항상 참입니다.
    • 이는 공집합인 never가 모든 집합의 부분 집합이기 때문입니다.
  • T extends never는 T가 never인 경우에만 true입니다.
    • 왜냐하면 공집합에는 자신을 제외한 서브셋이 없기 때문입니다.
  • T extends string은 T가 스트링, 리터럴, 리터럴 유니온 또는 템플릿이 될 수 있도록 허용합니다.
    • 이들 모두가 스트링의 서브셋이기 때문입니다.
  • T extends string ? string extends T ? true : false는 T가 정확히 string 타입인지를 확인합니다.

Unknown and Any

Typescript에는 임의의 JS 값을 나타낼 수 있는 두 가지 타입(unknown 및 any)이 있습니다.
일반적인 것은 unknown으로, JS 값의 전체집합 입니다.

// It's a 1
type Y = string | number | boolean | object | bigint | symbol | null | undefined extends unknown ? 1 : 0;
// a shorter one, given the {} oddity
type Y2 = {} | null | undefined extends unknown ? 1 : 0;
// For other types, this is 0:
type N = unknown extends string ? 1 : 0;

그러나 수수께끼 같은 측면이 있습니다.

  • unknown은 다른 모든 프리미티브 타입의 합집합이 아니므로 Exclude<unknown, string>을 사용할 수 없습니다.
  • unknown extends string | number | boolean | object | bigint | symbol | null | undefined 는 false 입니다
    • 즉 unknown이 더 큰 집합의 서브타입 이라는 것입니다. enum이 의심됩니다.

그래도 대체로 unknown을 "가능한 모든 JS 값의 집합"으로 생각하는 것은 안전합니다.

하지만 any는 정말 이상합니다.

  • any extends string ? 1:0은 0|1 입니다.
    •  이는 나도 몰라요를 의미합니다.
  • any extends never ? 1:0은 0|1 입니다.
    • 이는 any가 아마도 공집합일 수 있음을 의미합니다.
우리는 any가 NaN 타입처럼 "어떤 집합이지만 어떤 집합인지 확실하지 않다"고 결론을 내려야 합니다.
그러나 추가 검사에서 string extends any, unknown extends any 및 any extends any가 모두 true임을 알 수 있는데,
위의 조건과 이 조건을 전부 만족시키는 해당하는 어떤 집합은 없습니다.
따라서 any는 역설입니다.
모든 집합은 any의 부분 집합이지만 any는 공집합일 수도 있기 때문입니다.
 
제가 말씀드릴 수 있는 유일한 좋은 소식은 any extends unknown이기 때문에, unknown은 여전히 전체집합이고,
any는 전체집합 내부에 존재한다는 것입니다.
completed graph

오늘 우리는 TS 타입이 기본적으로 JS 값의 집합이라는 것을 배웠습니다.
다음은 type-world에서 set-world의 매핑 사전입니다.

  • 우리의 우주(전체집합) = 모든 JS 값 = unknown
  • never = 공집합
  • Subtype = narrowed type = subset
  • supertype = widened type = superset.
  • A extends B는 "A is subset of B"로 읽을 수 있습니다. (A는  B의 부분집합)
  • Union 및 Intersection 타입은 사실 집합의 Union 및 Intersection 연산입니다.
    • Exclude는 Union 집합 대상으로 동작하는 집합의 difference와 유사한 연산입니다.

맨 처음 문단의 질문에 대한 대답을 하겠습니다.

  • 0 | 1 extends 0은 {0,1}이 {0}의 하위 집합이 아니기 때문에 거짓입니다.
  • &,|은 집합을 대상으로 동작합니다. 모양을 대상으로 동작하는 것이 아닙니다.
    • A & B는 둘 다를 만족하는 집합입니다.
  • unknown은 모든 JS 값의 집합입니다.
    • any는 색즉시공, 공즉시색의 역설입니다.
  • never와 &하면 never입니다. 공집합이기 때문입니다.
    • union에는 영향을 미치지 않습니다.
  • const x:{}=true;는 정상입니다.
    • TS 인터페이스는 속성 제약 기능만 있기 때문입니다.
    • Primitive 밸류는 autoboxing에 의해 객체이며, 객체 리터럴 {}의 인터페이스를 만족합니다.
    • 만약 Primitive Value가 아닌 객체를 표현하고 싶다면, {} & object를 사용합니다.
      • 내장 object 타입은 '프리미티브가 아닌 JS 객체'를 의미합니다

참고

https://itchallenger.tistory.com/804

 

[타입스크립트] Object vs object vs {}

해당 스택오버플로우 게시물을 보고 요약한 글입니다. https://stackoverflow.com/questions/49464634/difference-between-object-and-object-in-typescript Difference between 'object' ,{} and Object in TypeScript Trying to figure out the diffe

itchallenger.tistory.com

 

 

반응형