JavaScript/TypeScript

The Type Hierarchy Tree: 타입스크립트의 Top, Bottom Type 더 깊게 바라보기

남희정 2023. 10. 2. 03:02

2023.09.29 - [JavaScript/TypeScript] - Type의 근원을 알아보고 타입스크립트의 Type System 바라보기

🔼 이전 글을 읽고 읽어보면 더욱 이해가 잘 될 것이다.

 

Top Type, Bottom Type

 

type system

TypeScript에서는 any와 unknown이라는 Top Type(최상위 타입: ⊤)과 never라는 유일한 Bottom type(최하위 타입)이 있다.

이것은 모든 타입들의 상한(Upper bound)하한(Lower bound)를 나타낸다.

 

TypeScript의 모든 Type은 계층 구조를 기반으로 자리를 잡고 있다. 

 

Supertype, Subtype

타입의 관계

✔️ 일반적인 타입을 슈퍼타입Supertype, 특수한 타입을 서브타입Subtype이라 한다. 
어떤 타입이 다른 타입의 서브타입이 되기 위해서는 행위적 호환성 (할당가능성)을 만족시켜야 한다.

 

할당 가능성/대체 가능성 assignability/substitutability

1️⃣ Type cast: 한 타입의 변수를 다른 타입의 변수에 할당하여 타입 에러가 발생하는지 확인할 수 있다.

하위 타입에서 상위 타입으로 할당하는 것을 업캐스트Upcast, 상위타입에서 하위 타입으로 할당하는 것을 다운캐스트Downcast라 한다. Liskov치환 원칙에 따라 업캐스트는 안전하므로 컴파일 문제 없이 암시적으로 수행할 수 있다. 하지만 보통 다운캐스트의 경우 안전하지 않고 대부분의 정적 타이핑 언어는 이것을 자동으로 허용하지 않는다. (any는 예외..)

 

let string: string = 'foo'
let any: any = string // ✅ ⬆️upcast
let unknown: unknown = string // ✅ ⬆️upcast

let any: any
let unknown: unknown
let stringA: string = any // ✅ ⬇️downcast - it is allowed because `any` is different..
let stringB: string = unknown // ❌ ⬇️downcast

 

2️⃣ extends 키워드를 사용하면 한 유형을 다른 유형으로 확장할 수 있다.

 

type A = string extends unknown? true : false;  // true
type B = unknown extends string? true : false; // false

 

즉, 서브타입은 슈퍼타입을 대체할 수 있다.

서브타입은 슈퍼타입의 행위에 추가적으로 특수한 자신만의 행동을 추가하는 것, 슈퍼타입의 행동은 서브타입에게 자동으로 상속된다.

이렇게 상위 타입/하위 타입 관계가 적용되는 두 가지 방법이 있다.

 

명목 / 구조적 타이핑 Nominal and Structural typing

대부분의 주류 정적 타입 언어(Java 등)가 사용하는 첫 번째 방법은 명목 타이핑 Nominal Typing 이라 한다. 이는 타입을 명시적으로 Class Foo extends Bar와 같은 구문을 통해 다른 타입의 서브 타입임을 선언한다.

타입스크립트가 사용하는 구조적 타이핑Structural Typing 은 코드에서 관계를 명시할 필요가 없다. 명목적 서브타이핑과 동일한 효과를 내면서도 개발자가 상속 관계를 명시해주어야 하는 수고를 덜어주게 된다. 

"만약 어떤 새가 오리처럼 걷고, 헤엄치고, 꽥꽥거리는 소리를 낸다면 나느 그 새를 오리라고 부를 것이다"라는 의미에서 덕 타이핑 Duck Typing이라고도 한다.

 

TypeScript의 타입 호환성은 구조적 서브 타이핑(subtyping)을 기반으로 합니다. 구조적 타이핑이란 오직 멤버만으로 타입을 관계시키는 방식입니다. 명목적 타이핑(nominal typing) 과는 대조적입니다.
- TypeScript 문서 Type Compatibility 섹션

 

type Supertype = { x: boolean }
type Subtype = { x: boolean, y: number }

 

Supertype의 값은 타입이 boolean인 프로퍼티 x를 가진 객체이다. Subtype의 값은 마찬가지로 타입이 boolean인 프로퍼티 x를 가지지만 동시에 타입이 number인 프로퍼티 y도 가지는 객체이다.

Subtype의 값은 Supertype의 값이기도 하여 Supertype은 Subtype의 슈퍼타입이고 Subtype은 Supertype의 서브타입이 성립된다.

{name: string, age: number} 타입은 {name: string} 타입의 서브 타입

Type의 Lower 타입 인스턴스를 해당 Upper 타입의 인스턴스로 할당/대체할 수 있지만 그 반대는 안되는 것을 유의하자.

SubType의 조건이 더욱 엄격하다고 기억하면 쉽다.

 

Subtype <: Supertype

 

슈퍼타입(상위)과 서브타입(하위), 두 타입 간의 포함관계를 서브타입 관계라고 하며 <: 를 통해 서브타입 <: 슈퍼타입 형식으로 표현한다.

위에서 언급한 Top, Bottom에 대입하여 연결해보겠다.

 

The top of the tree

Top Type은 가능한 모든 값을 나타내는 타입이다. 모든 다른 타입의 값은 타입이 Top인 위치에 제공될 수 있다. 즉, 모든 타입은 Top타입에 할당할 수 있다.

타입스크립트에는 다른 모든 타입의 최상위 타입인 anyunknown의 두 가지 타입이 있다.

🌟 탑 타입은 모든 타입의 슈퍼타입으로 모든 타입의 값을 값으로 갖는다고 말했다. 또한 그렇기 때문에 모든 타입의 값에 대해 공통적으로 할 수 있는 연산 외에는 그 어떤 연산도 할 수 없다는 점이 주요 특징이다.

 

Unknown 

TypeScript에서 탑타입의 조건에 부합하는 Top Type은 unknown이다. 

 

let userInput: unknown;

userInput = 5;

console.log(userInput.toFixed(2));  // Error: 'unknown' 타입에 'toFixed' 연산 불가
console.log(userInput.toUpperCase());  // Error: 'unknown' 타입에 'toUpperCase' 연산 불가
console.log(userInput.length);  // Error: 'unknown' 타입에 'length' 프로퍼티 접근 불가

// 'unknown' 타입을 'number'로 타입 단언하여 연산 가능하게 변환
console.log((userInput as number).toFixed(2));  // 올바른 숫자 연산 가능

왜 탑 타입의 값에 대해선 모든 타입의 값에 대해 공통적으로 할 수 있는 연산 외에 어떤 연산도 할 수 없을까?

탑 타입은 모든 타입의 상위 타입이므로, 그 어떤 타입도 자신보다 상위에 위치하지 않는다. 그 값이 가지고 있는 모든 연산을 포괄하는 최상위 개념이기 때문에  집합의 개념에서 모순일 뿐더러 안전한 타입이 되려면 당연히 모든 타입의 값에 적용 가능한 연산만 적용할 수 있어야 함이 마땅하기 때문이다.

 

let userInput: any;

userInput = 5;

console.log(userInput.toFixed(2));  // 유효한 호출, 실행됨
console.log(userInput.toUpperCase());  // 유효한 호출, 실행됨
console.log(userInput.length);  // undefined 출력

// 'any' 타입으로 선언한 경우 타입 단언은 필요하지 않음
console.log(userInput.toFixed(2));  // 올바른 숫자 연산 가능

Any

any는 모순적인 탑 타입이다. 우선 모든 타입의 슈퍼타입이기 때문에 탑 타입이라 여기지만 위에서 언급된 탑 타입의 특징을 무시한다. 이런 점을 any타입이 JavaScript세계로 빠져나갈 수 있는 백도어Backdoor 역할을 한다고도 말한다. 또한 Type 검사를 우회하고 JavaScript와 유사한 동작을 가능하게 하여 타입 안정성을 감소시키는 점도 그러하다. 

 

Why Any?

타입스크립트가 이런 모순적인 타입을 만든 이유는 무엇일까?

"자바스크립트 코드를 그대로 사용할 수 있게 하여 타입스크립트의 생산성을 높여주는 데에 있다."타입스크립트의 디자인 목표에서 추측해볼 수 있다.

 

타입스크립트의 개발 목표는 안전한 타입 시스템을 도입하여 자바스크립트에서 발생할 수 있는 모든 오류를 완전히 100% 걷어내는 것에 있는게 아니라, 기존의 자바스크립트의 생산성을 보전하면서 오류가 될 수 있는 코드들을 미리 걸러주는 거름망 같은 타입 시스템을 도입하는데에 있었기 때문에 any와 같이 안전성에 위협이 되더라도 기존의 생산성을 보전하는 데에 도움이 되는 타입탈출구Escape hatch로 만들어두지 않았을까 생각한다.

 

어떤 값이든 될 수 있음을 나타내려면 unknown 타입이 훨씬 안전하다..!

 

The bottom of the tree

Never

never 타입은 더 이상 가지가 뻗어나갈 수 없는 트리의 유일한 최하위 타입이다. (공집합) any와 unknown의 반대 타입이기 때문에 그들이 모든 값을 허용한다면 never의 경우엔 모든 값(any 등)을 절대로 허용하지 않는다.

TypeScript는 never 타입을 empty 타입(uninhibitable type)으로 취급한다. 즉, 런타임에 실제 값을 가질 수 없고 타입으로 아무것도 할 수 없는 타입이다.

TypeScript Type tree

할당가능성과 서브타입, 슈퍼타입을 연관지어서 위의 Type tree를 보면 전체적인 구조를 이해할 수 있을 것이다. 이전 포스팅에서도 언급했던 void도 간단히 연관지어 설명하고 이번 포스팅을 마치겠다.

 

Void

C++과 Java에서의 void는 리턴값이 없는 함수의 return Type을 의미한다. TypeScript에서의 void는 undefined의 상위 타입이다.

undefined를 void에 할당Upcast할 수 있지만 그 반대Downcast를 할당할 수 없다.

 

type A = undefined extends void ? true : false;  // true
type B = void extends undefined ? true : false; // false

 

 


🤔💭

실질적인 쓰임새라던가, 예시를 크게 설명할 수 없어서 아쉽지만 이런 타입시스템에 대한 이해를 확실히 하고 접근하는 것에 대해선 긍정적으로 생각한다. 저번 포스팅에서 객체지향에서 배운 개념을 좀더 심도있게 파고들지 못해 아쉬웠는데 이번에 그 부분을 보충할 수 있었던 것 같다. 코드에 있어선 훨씬 뛰어난 분들도 많고 스스로 해보아야 늘어나는 것이기 때문에 근본적인 방향으로 블로그 포스팅을 하려는 이유도 그 이유이다. 배울 것이 넘치기때문에 하나씩 심도있게 가지를 뻗어나가면서 포스팅해보겠다.

 

 

[The Type Hierarchy Tree]

[집합의 관점에서 타입스크립트 바라보기]

[Top and bottom types]

[안전한 any 타입 만들기]

[TypeScript 타입시스템 뜯어보기:타입 호환성]

[Type Compatibility]

[[TS] 9. unknown Type]

[타입스크립트 타입 호환성과 타입 계층 트리]