ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • Effective Typescript - type을 value들의 집합이라 생각하기
    Typescript 2023. 9. 3. 22:20

    🔔 서론

    뭐했는지 벌써 8월이 끝나고 9월이 찾아왔다. 이래 저래 고민이 많은 시기인데.. 이런 고민에 대한 얘기는 월 회고록에 주저리주저리 얘기해보도록 하고, 이번 주 WIL은  프로그래머스 데브코스에서는 react 과제를 수행하느라 별 다른 학습을 진행하진 않았고, 대신 effective typescript 스터디를 하면서 읽은 내용중 인상깊었던 내용에 대해서 정리를 해보려고한다.


    🔔 타입이 값들의 집합이라고 생각하기

    타입스크립트의 런타임에서 모든 변수는 자바스크립트의 값으로부터 정해지는 각자의 고유한 값을 가진다. 예를들어 변수는 다음과 같은 다양한 값을 가질 수 있다.

    • 42
    • null
    • undefined
    • 'abcd'
    • (a, b) => a + b
    • {name: 'kim'}


    이들은 코드를 실행하기 전, 타입스크립트가 오류를 체크하는 순간에 '타입'을 가지고 있다.

    이제 이 '타입'을 '할당 가능한 값들의 집합' 이라고 생각해보자. 그럼 이 집합은 타입의 '범위'라고 생각할 수 있다.

    예를들어 숫자 42나 36.5는 number 타입이라는 집합에 속하고, 'abc'라는 문자열은 그렇지 않다. null과 undefined는 tsconfig strictNullChecks 옵션의 여부에 따라 number에 해당될 수 있고 아닐 수도 있다.

    집합에서 가장 작은 집합은 공집합인데, 타입스크립트에서는 never 타입이 이에 해당된다. never 타입으로 선언된 변수는 공집합이기 때문에 어떤 값도 할당할 수 없다.

    const x: never = 12
    // ~ '12' 형식은 'never' 형식에 할당할 수 없습니다.

    그 다음 작은 집합은 한 가지 값만 포함하는 타입이다. 타입스크립트에서는 유닛(unit)이라고 불리는 리터럴(literal)타입이다.

    type A = 'A'
    type B = 'B'
    type twelve = 12

    이러한 리터럴 타입들은 두 개 혹은 세 개로 묶을 수 있는데 이 때 유니온(union) 타입을 사용한다.

    type AB = 'A' | 'B'
    type CDE = 'C' | 'D' | 'E'

    유니온 타입은 값 집합들의 합집합이라고 이해하면 된다.

    타입스크립트의 오류에서 '할당 가능한' 이라는 문구를 자주 볼 수 있다. 이 문구는 앞서 서술한 집합이라는 관점에서는 '~의 원소(값과 타입의 관계)' 혹은 '~의 부분 집합(두 타입의 관계)'을 의미한다. 타입스크립트의 타입체커의 역할 역시 집합의 관점에서 하나의 집합이 다른 집합의 부분집합인지 검사하는 것이라고 생각하면 된다.

    다음은 교집합에 해당되는 타입의 인터섹션(intersection)에 대해 알아보자.

    interface Person {
      name: string
    }
    
    interface Lifespan {
      birth: Date
      death?: Date
    }
    
    type PersonSpan = Person & Lifespan

    & 연산자는 두 타입의 intersection (교집합)을 계산한다. 교집합이라고 생각했을 때 Person과 Lifespan 인터페이스는 공통으로 가지는 속성이 없기 때문에, PersonSpan 타입을 공집합(never 타입)이라고 예상할 수 있는데, 타입 연산자는 인터페이스의 속성이 아닌, 값의 집합에 적용된다. 그리고 추가적인 속성을 가지는 값도 여전히 그 타입에 속한다. 그래서 Person과 Lifespan을 둘 다 가지는 값이 인터섹션 타입인 PersonSpan에 속하게 된다.

    const ps: PersonSpan = {
      name: 'kim'
      birth: new Date('2023/01/01')
      death: new Date('2123/01/01')
    }
    // 정상

    조금 더 일반적으로 PersonSpan 타입을 선언하는 방법은 extends 키워드를 사욯하는 것이다.

    interface Person {
      name: string
    }
    
    interface PersonSpan extends Person {
      birth: Date
      death?: Date
    }

    extends 키워드는 집합의 관점에서 '~의 부분 집합'이라는 의미로 받아들이면 된다. PersonSpan 타입의 모든 값은 문자열의 name 속성을 가져야 하고, birth 속성을 가져야 Person.의 부분집합이 된다.

    '서브타입' 역시 어떤 집합이 다른 집합의 부분집합이라는 의미로 이해하고, 벡터의 예시를 보자

    interface Vector1D { x: number }
    interface Vector2D extends Vector1D { y: number }
    interface Vector3D extends Vector2D { z: number }

    Vector3D는 Vector2D의 서브타입이고, Vector2D는 Vector1D의 서브타입이다. 보통 이 관계는 상속 관계로 그려지지만, 집합의 관점에서는 벤 다이어 그램으로 그리는게 적절하다.

    extends 키워드는 제네릭 타입에서는 한정자로 쓰이는데 여기서도 집합의 관점에선 '~의 부분집합'을 의미한다.

    function getKey<K extends string>(val: any, key: K) {
    	// ...
    }

    위의 예시는 string을 상속한다는 의미를 객체 상속의 관점으로 생각하면 이해하기 어려울 수 있다. string의 부분 집합 범위를 가지는 어떠한 타입이 된다고 이해하면 이 타입은 string 리터럴 타입, string 리터럴 타입의 유니온, string 자신을 포함하는 것을 이해할 수 있다.

    getKey({}, 'x')	// 정상, 'x'는 string의 부분집합
    getKey({}, Math.random() < 0.5 ? 'a' : 'b') // 정상, 'a'|'b'는 string의 부분집합
    getKey({}, document.title)	// 정상, string은 string의 부분집합
    getKey({}, 12)	// ~~ '12' 형식의 인수는 'string' 형식의 매개변수에 할당될 수 없습니다.

    마지막 오류의 '할당될 수 없습니다'는 상속의 관점에서는 '상속할 수 없습니다'로 바꿀 수 있고, 두 표현 모두 '~의 부분 집합'의 의미로 받아들일 수도 있다. 이렇게 할당과 상속의 관점을 집합의 관점으로 전환해 보면 객체의 키 타입을 반환하는 keyof T를 이해하기도 수월하다.

    interface Point {
      x: number
      y: number
    }
    
    type PointKeys = keyof Point	// 'x' | 'y'
    
    function sortBy<K extends keyof T, T>(vals: T[], key: K): T[] {
      // ...
    }
    
    const pts: Point[] = [ {x: 1, y: 1}, {x: 2, y: 0} ]
    sortBy(pts, 'x')	// 정상, 'x'는 'x'|'y'의 부분집합
    sortBy(pts, 'y')	// 정상, 'y'는 'x'|'y'의 부분집합
    sortBy(pts, Math.random() < 0.5 ? 'x' : 'y')	// 정상, 'x'|'y'는 'x'|'y'의 부분집합
    sortBy(pts, 'z')	// ~~ 'z' 형식의 인수는 'x'|'y'형식의 매개변수에 할당될 수 없습니다.

     

    타입들이 엄격한 상속 관계가 아닐 때 집합의 관점으로 보는게 더욱 바람직하다. 예를 들어, 'string|number' 와 'string|Date' 사이의 intersection은 공집합(never 타입)이 아니며 서로의 부분 집합도 아니다. 이 타입들은 엄격한 상속 관계가 아니더라도 범위에 대한 관계는 명확하다.

    집합의 관점은 배열과 튜플의 관계 역시 명확하게 만든다. 예를 들면, 

    const list = [1, 2]
    const tuple: [number, number] = list
    // ~~ 'number[]' 타입은 '[number, number]' 타입의 0, 1 속성에 없습니다.

    number[]는 [number, number]의 부분 집합이 아니기 때문에 할당 할 수가 없다(반대의 경우는 동작). 또한, 튜플 타입은 튜플의 길이까지도 체크하기 때문에 구조적 타이핑의 관점에서 생각하여 추가적인 속성이 있더라도 할당 가능할 것으로 생각하면 안된다.

    const triple: [number, number, number] = [1, 2, 3]
    const double: [number, number] = triple
    // ~~ '[number, number, number]' 형식은 '[number, number]' 형식에 할당할 수 없습니다.
    //    'length' 속서의 형식이 호환되지 않습니다.
    //    '3' 형식은 '2' 형식에 할당할 수 없습니다.

     

    🔔 요약

     

    타입스크립트 용어 집합
    never ∮(공집합)
    리터럴 타입 원소가 1개인 집합
    값이 T에 할당 가능 값 ∈ T
    T1이 T2에 할당 가능 T1 ⊆ T2
    T1이 T2를 상속 T1 ⊆ T2
    T1 | T2 T1 ∪ T2
    T1 & T2 T1 ∩ T2
    unknown 전체(universal) 집합
    • 타입을 값의 집합으로 생각하면 이해하기 편하다. 이 집합은 유한(boolean 또는 리터럴 타입)하거나 무한(number 또는 string)하다.
    • 타입스크립트 타입은 엄격한 상속 관계가 아니라 겹쳐지는 집합(벤 다이어그램)으로 표현된다. 두 타입은 서로 서브타입이 아니면서도 겹쳐질 수 있다.
    • 한 객체의 추가적인 속성이 타입 선언에 언급되지 않더라도 그 타입에 속할 수 있다(구조적 타이핑).
    • 타입 연산은 집합의 범위에 적용된다. A와 B의 인터섹션은 A의 범위와 B의 범위의 인터섹션이다. 객체 타입에서는 A & B인 값이 A와 B의 속성을 모두 가짐을 의미한다.
    • 'A는 B를 상속', 'A는 B에 할당 가능', 'A는 B의 서브타입'은 모두 'A는 B의 부분 집합'과 같은 의미다.

    💊 후기

    사실 typescript를 처음 학습하고 있는상황이 아니라 타입에 대한 관계나 상속, 할당 이런 부분은 어느정도 이해하고 있는 상황이었다. 하지만 타입이 값들의 집합이라고 생각하고 이해하고 있는 부분을 집합의 관점에서 바라보니 헷갈리던 부분도 조금 더 명확하게 이해할 수 있었던 놀라움 때문에 이번 챕터를 다시 한번 공부하면서 정리하게 되었다.

Designed by Tistory.