본문 바로가기

FrontEnd

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

반응형

타입스크립트의 타입 계층을 통해 타입 호환성을 알아봅시다.

1. 타입 호환성은 뭔가요?

타입 호환성

 

Documentation - Type Compatibility

How type-checking works in TypeScript

www.typescriptlang.org

타입스크립트에는 다양한 타입이 존재하는데, 어떠한 타입 변수에 다른 타입 데이터(값)이 할당 가능하면 타입이 호환된다고 합니다.

타입스크립트는, 구조적 타이핑을 허용하기에, 클래스 명이 다르면 하위 구조가 완전 같아도 호환이 불가능한 자바의 명목 타이핑과 다른 동작을 보여줍니다.

기본적으로 타입 호환성은 업캐스팅 가능 여부로 판단합니다. 

이 게시물에서는 타입스크립트 기본 타입 간의 계층 구조를 이해하며, 타입 호환성을 알아봅니다.

 

 

아래 표가 어렵다면 해당 문단을 생략하고 아래 문단부터 읽으시면 됩니다.

또한 집합을 의미하는 타입(ts)과 타입의 인스턴스인 값(js)을 혼동하면 안됩니다.

 

다음 표에는 일부 추상 타입 간의 할당 가능성이 요약되어 있습니다.

행(-> row)할당될 대상 타입 변수를 나타내고 (= 좌변)

열(^ column)할당할 타입 데이터를 나타냅니다. (= 우변)

""는 strictNullChecks가 꺼져 있을 때만 호환되는 조합을 나타냅니다. (보통 사용하지 않으니 무시해도 괜찮습니다.)

데이터 \ 변수 any unknown object void undefined null never
any →  
unknown →  
object →  
void →  
undefined →  
null →  
never →  
  • 모든 타입은 자신에게 할당할 수 있습니다.
  • any와 unknown은 할당할 수 있는 타입 항목이 거의 같지만, unknown은 any 타입 변수에만 할당할 수 있습니다.
  • unknow과 never는 역함수와 같습니다. (전체집합과 공집합.)
    • unknown 타입 변수에는 어떤것이나 할당할 수 있지만, never 타입 변수에는 어떤것도 할당할 수 없습니다. (any 도 never 할당 불가능)
      • unknown은 전체집합이므로 그 어떤 타입도 전체집합의 서브타입이 될 수 있습니다.
      • never는 공집합이므로 그 어떤 타입도 공집합의 서브타입이 될 수 없습니다.
    • unknown 타입은 any 타입 변수를 제외하고 어디에도 할당할 수 없지만, never 타입은 어디에나 할당할 수 있습니다.
  • void 타입은 any, never, undefined 및 null(strictNullChecks가 꺼져 있는 경우 자세한 내용은 표 참조) 타입 변수를 제외하고 다른 타입에 할당할 수 없습니다.
    • void는 never, undefined의 슈퍼 타입입니다.
  • strictNullChecks가 꺼져 있으면 null 및 undefined는 never(공집합)와 유사합니다.
    • null과 undefined는 대부분의 타입 변수에 할당 가능하고 대부분의 타입은 null과 undefined 타입 변수에 할당할 수 없습니다.
    • null과 undefined는 서로 할당 가능합니다.
  • strictNullChecks가 켜져 있으면 null 및 undefined는 void처럼 동작합니다.
    • null 및 undefined 타입 변수에는 any, unknown, never 및 void(undefined는 항상 void에 할당 가능)타입을 제외하고 할당할 수 없습니다.

2. 실력 체크

이제 , 다음 TypeScript 코드 스니펫을 읽고 각 할당에 대한 타입 오류가 있는지 여부를 예측해봅시다.
팁 : 할당가능성===업캐스트 가능성입니다.
// 1. any and unknown
let stringVariable: string = 'string'
let anyVariable: any
let unknownVariable: unknown 

anyVariable = stringVariable // any <- string
unknownVariable = stringVariable // unknown <-string
stringVariable = anyVariable // string <- any 
stringVariable = unknownVariable // string <- unknown
정답 : 4번에서 타입 오류 발생

unknown은 any에만 할당할 수 있다.
unknown과 any는 모든 타입의 슈퍼타입이다.(unknown 변수와 any에는 어떤 것이나 할당할 수 있다.)
unknown은 전체집합이다. 따라서 unknown 타입은 any와 unknown에만 할당 가능하다.

any는 모든 타입의 슈퍼타입이라며, string으로 업캐스팅이 가능하다고?
any는 치트키이므로 never를 제외한 모든 타입 변수에 할당 가능하다. (never 제외 모든 타입의 서브타입처럼 동작 -unknown과 다르다)
반대로 모든 타입도 자신에 할당 가능하다. (모든 타입의 슈퍼타입)
즉 정상적인 타입 시스템의 논의에 벗어난다.
// 2. `never` 
let stringVariable: string = 'string'
let anyVariable: any
let neverVariable: never

neverVariable = stringVariable // 1 never <-string
neverVariable = anyVariable // 2 never <-any
anyVariable = neverVariable // 3 any <- never
stringVariable = neverVariable // 4 string <-never
정답 : 1,2번에서 타입 오류

1. never는 모든 타입의 서브타입임. 따라서 never에는 어떤 업캐스트도 허용하지 않음.
2. any는 모든 타입의 슈퍼타입이지만, never를 제외한 모든 타입의 서브타입이기도 함. 따라서 never로 업케스팅이 불가능.
(반대로 never는 any로 업캐스팅이 가능하다.)
// 3. `void` pt. 1
let undefinedVariable: undefined 
let voidVariable: void
let unknownVariable: unknown

voidVariable = undefinedVariable // void <-void
undefinedVariable = voidVariable // undefined <-void
voidVariable = unknownVariable // void <- unknown
정답 : 2, 3번에서 타입 오류
1 : 자기 자신 할당은 항상 가능
2 : void는 undefined의 슈퍼 타입임. 따라서 2번은 다운캐스팅이므로 오류
3 : unknown은 void의 슈퍼타입임. (전체집합)
// 4. `void` pt. 2

function fn(cb: () => void): void {
    return cb()
}

fn(() => 'string')
정답 : 오류 없음 (??)
타입스크립트 컴파일러는 void 리턴 값을 명시적으로 활용하는게 아닌 이상 오류를 표시하지 않음.
타입스크립트가 js interop을 위해 허용하는 부분임.

(void는 string의 슈퍼타입이 아님!!)
// 5. `void` pt. 2 answer

function fn(cb: () => void): void {
    return cb()
}

const a = fn(() => 'string');  // const a : void

a+1 // Operator '+' cannot be applied to types 'void' and 'number'.(2365)
void 리턴 타입으로 string은 허용하지만,
위와 같이 void 타입으로 뭔가를 하려하면 오류 발생함.

3. 타입 계층 트리를 살펴보기 전에

타입 계층 트리

TypeScript의 모든 타입은 계층 구조에서 자리를 차지합니다. 나무와 같은 구조로 시각화할 수 있습니다.
즉 타입은 트리의 부모 노드와 자식 노드가 될 수 있습니다.
타입 시스템에서 이러한 관계에 대해 상위 노드를 슈퍼 타입으로, 하위 노드를 서브 타입이라고 합니다.
 
서브 타입은 슈퍼 타입에 할당할 수 있습니다.
객체 지향 프로그래밍에서 잘 알려진 개념 중 하나인 상속에 대해 잘 알고 있을 것입니다.
상속은 자식 클래스와 부모 클래스 사이에 is-a 관계를 설정합니다. (자식타입 is 슈퍼타입)
부모 클래스가 Vehicle(차량)이고 자식 클래스가 Car(자동차)인 경우 관계는 "Car(자동차) is(는) Vehicle(차량)"입니다.
그러나 반대 방향으로는 작동하지 않습니다.
자식 클래스의 인스턴스는 부모 클래스의 인스턴스가 아닙니다.
즉 Vehicle(차량)는 car(자동차)가 아닙니다.
 
이것이 상속의 의미론이며 TypeScript의 타입 계층에도 적용됩니다.
 
Liskov 치환 원칙에 따르면
Vehicle(상위 타입)의 인스턴스는 프로그램의 정확성을 변경하지 않고 자식 클래스(하위 유형) Car의 인스턴스로 대체할 수 있어야 합니다.
즉, 타입(vehicle)에서 특정 동작을 기대하면 해당 하위 타입(car)이 이를 존중해야 합니다.
Liskov 대체 원리는 박사 학위를 위해 작성된 30년 전의 논문에서 나온 개념입니다.
하나의 블로그 게시물에서 다룰 수 없는 수많은 뉘앙스가 있습니다.
 
이를 종합하면 TypeScript에선 서브 타입 인스턴스를 해당 (수퍼) 타입의 인스턴스에 할당/대체할 수 있지만 그 반대는 불가능합니다.

명목 / 구조적 타이핑(nominal and structural typing)

상위 타입/하위 타입 관계가 적용되는 두 가지 방법이 있습니다.
 
대부분의 주류 정적 타입 언어(예: Java)가 사용하는 첫 번째 방법은 명목 타이핑(nominal typing)이라고 합니다.
여기서 타입을 명시적으로 선언해야 하는 경우 Class Foo extends Bar와 같은 구문을 통해 다른 타입의 서브 타입임을 선언합니다.
 
TypeScript가 사용하는 두 번째 방법은 구조적 타이핑(structural typing)으로, 코드에서 관계를 명시할 필요가 없습니다.
Foo 타입의 인스턴스는 Foo에 몇 가지 추가 멤버가 있더라도 Bar 타입 있는 모든 멤버가 있는 한 Bar의 서브 타입입니다.
이 상위 타입-하위 타입 관계에 대해 생각하는 또 다른 방법은 타입이 더 엄격한지 확인하는 것입니다.
더 엄격하면 하위 타입입니다.
{name: string, age: number} 타입은 {name: string} 타입보다 더 엄격합니다.
전자는 해당 인스턴스에 정의된 더 많은 멤버가 필요하기 때문입니다. 
 
따라서 {name: string, age: number} 타입은 {name: string} 타입의 서브 타입입니다.

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

할당 가능성을 파악하는 두가지 방법 (two ways of checking assignability/substitutability)

TypeScript의 타입 계층 트리를 살펴보기 전에 마지막으로 살펴볼 두 가지 개념입니다.
  • 타입 캐스트: 한 타입의 변수를 다른 타입의 변수에 할당하여 타입 오류가 발생하는지 확인할 수 있습니다. 나중에 자세히 설명합니다.
  • extends 키워드 - 한 타입을 다른 타입으로 확장할 수 있습니다.
type A = string extends unknown? true : false;  // true - 스트링은 unknown에 할당 가능
type B = unknown extends string? true : false; // false - unknown은 string에 할당 불가능

 

4. 타입 계층 트리

트리의 꼭대기 (the top of the tree)

타입 계층 트리에 대해 이야기해 보겠습니다.
 
TypeScript에는 다른 모든 타입의 슈퍼 타입인 any 타입과 unknown 타입이 있습니다.
어떤 타입이든 해당 타입의 변수에 할당 가능합니다.
어떤 타입이든 해당 타입의 변수에 할당 가능합니다.

업캐스트 & 다운캐스트 (upcast & downcast)

타입 캐스트에는 업캐스트와 다운캐스트의 두 가지 종류가 있습니다.
상위 타입에 하위 타입을 할당하는 것을 업캐스트라고 합니다.
 
상위 타입에 하위 타입을 할당하는 것을 업캐스트라고 합니다.
Liskov 치환 원칙에 따라 업캐스트는 안전하므로 컴파일 문제 없이 암시적으로 수행할 수 있습니다.
TypeScript가 암시적 업캐스트를 허용하지 않는 예외가 있습니다. 포스팅 말미에 다루도록 하겠습니다.
더 엄격한 서브 타입을 더 일반적인 슈퍼 타입으로 대체하여 트리 위로 올라가는 것과 유사한 업캐스트를 생각할 수 있습니다.
예를 들어, 모든 문자열 타입은 any, unknown의 서브 타입입니다. 즉, 다음 할당이 허용됩니다.
let string: string = 'foo'
let any: any = string // ✅ ⬆️upcast
let unknown: unknown = string // ✅ ⬆️upcast
반대는 다운 캐스트라고합니다. 더 일반적인 (수퍼) 타입을 더 엄격한 하위 타입으로 대체하여 트리를 걸어 내려가는 것으로 생각하십시오.
업캐스트와 달리 다운캐스트는 안전하지 않으며 대부분의 정적 타이핑 언어는 이를 자동으로 허용하지 않습니다.
예를 들어, 문자열 타입에 any, unknown 할당은 downcast입니다.
let any: any
let unknown: unknown
let stringA: string = any // ✅ ⬇️downcast - it is allowed because `any` is different..
let stringB: string = unknown // ❌ ⬇️downcast

unknown을 문자열 타입에 할당하면 TypeScript 컴파일러는 타입 오류를 표시합니다.
이는 타입 검사기를 명시적으로 우회하지 않고는 수행할 수 없는 다운캐스트이기 때문입니다.
그러나 TypeScript는 우리의 이론과 모순되는 것처럼 보이는 문자열 타입에 any를 할당하는 것을 기꺼이 허용합니다.

any의 예외는 TypeScript에서 모든 타입이 JavaScript 세계로 탈출하기 위한 백도어 역할을 하기 때문입니다.
이는 JavaScript의 전반적인 유연성을 반영합니다.
 
Typescript는 타협입니다.
이 예외는 설계상의 일부 오류로 인한 것이 아니라 여기에서 런타임 언어가 여전히 JavaScript기에,
타입스크립트가 실제 런타임 언어가 아닌 특성이 반영된 것이라 보는것이 좋습니다.
 
주 : 지금까지, unknown은 전체집합, any는 백도어(escape hatch)정도로 보는게 좋습니다.

트리의 밑바닥(the bottom of the tree)

never 타입은 더 이상 가지가 확장되지 않는 트리의 맨 아래입니다.
(주 : 공집합)

대칭적으로, never 타입은 최상위 타입인 any 및 unknow의 반대 타입처럼 작동합니다.
any 및 unknown은 모든 값을 허용(할당 가능)하지만,
never는 어떤 것도(any 타입의 값 포함)을 허용(할당 가능)하지 않습니다.
never는 모든 타입의 서브타입이기 때문입니다.
let any: any 
let number: number = 5
let never: never = any // ❌ ⬇️downcast 
never = number // ❌ ⬇️downcast 
number = never // ✅ ⬆️upcast
Liskov 치환 원칙에 따르면, never는 무한대의 타입과 멤버를 가질 수 있습니다.
모든 슈퍼타입을 never로 대체할 수 있기 때문입니다.
never는 모든 타입스크립트의 타입으로  업캐스팅 할 수 있습니다.
 
never의 업캐스팅 후에도 프로그램은 정상적으로 동작해야 합니다만,
기술적으로 이는 불가능합니다.
대신 TypeScript는 never 타입을 empty type (a.k.a uninhibitable type)으로 취급합니다.
즉, 런타임에 실제 값을 가질 수 없고 타입으로 아무 것도 할 수 없는 타입(예: 인스턴스의 해당 타입 속성에 액세스)입니다.
 

표준 사용 사례 (canonical usecase) : never는 는 값을 리턴하면 안되는 함수의 리턴 값을 표시하는데 사용됩니다. 

함수는 여러 가지 이유로 반환되지 않을 수 있습니다.
모든 코드 경로에서 예외를 throw할 수 있고
이벤트 루프와 같이 전체 시스템이 종료될 때까지 계속 실행하려는 코드가 있기 때문에 영원히 루프할 수 있습니다.
이 모든 시나리오는 유효합니다.

function fnThatNeverReturns(): never {
	throw 'It never returns'
}
const number: number = fnThatNeverReturns() // ✅ ⬆️upcast
위의 할당이 처음에는 잘못된 것처럼 보일 수 있습니다.
never가 empty type이라면 왜 숫자 타입에 할당할 수 있습니까?
컴파일러가 우리 함수가 절대 반환하지 않는다는 것을 알고 있기 때문에 아무 것도 숫자 변수에 할당되지 않는다는 것을 알고 있기 때문입니다.
타입은 런타임 시 데이터가 올바른지 확인하기 위해 존재합니다.
할당이 런타임에 실제로 발생하지 않고 컴파일러가 미리 그것을 확실히 알고 있다면 타입은 중요하지 않습니다.
(컴파일러는 해당 값을 가지고 뭔가 하려고 하면 오류를 발생시킵니다.)
never 타입을 생성하는 또 다른 방법은 호환되지 않는 두 타입을 교차하는 것입니다. {x: 숫자} 및 {x: 문자열}.
교집합 속성이 discriminant property(대략, 값이 리터럴 타입 또는 리터럴 타입의 union인 속성)으로 간주되는 경우
필드 뿐만 아니라 전체 타입이 never로 축소됩니다. 이것은 TypeScript 3.9에 도입된 기능입니다.  
자세한 내용과 동기는 아래 PR을 확인하세요.

즉 타입 비결정성 (name:string & number) 뿐만 아니라 {kind:'a' & 'b'} 또한 never로 취급한다는 뜻입니다.

또한 이 경우 {name:never},{kind:never, otherfield:number} 이런 식이 아니라 타입을 통로 never로 변환합니다.

해당 PR 보러가기 this PR

type A = { kind: 'a', foo: string };
type B = { kind: 'b', foo: number };
type C = { kind: 'c', foo: number };

type AB = A & B;  // never
type BC = B & C;  // never

 

보충설명

객체 타입의 intersection 시 교집합 속성이 판별 속성(discriminant properties - 리터럴 타입 또는 리터럴 타입의 합집합)으로 간주되는지 여부에 따라 전체 타입을 축소하지 않을 수도 있습니다.
이 예에서는 string과 number가 판별 속성이 아니므로 name 속성만 never가 됩니다.
type Foo = {
    name: string,
    age: number
    }
type Bar = {
    name: number,
    age: number
}

type Baz = Foo & Bar // {name: never, age: number}
boolean은 판별 속성(true | false의 합집합) 취급이기 때문에 전체 타입 Baz가 never로 축소됩니다.
type Foo = {
    name: boolean,
    age: number
    }

type Bar = {
    name: number,
    age: number
}

type Baz = Foo & Bar // never

(주 : 둘 다 못써먹는 타입인건 동일함)

트리 꼭대기와 밑바닥 사이 타입들

트리 꼭대기의 슈퍼 타입과 트리 밑바닥의 서브 타입에 대해 이야기했습니다.
그 사이의 타입은 숫자, 문자열, 부울, 객체와 같은 primitive types 입니다.
올바른 멘탈 모델을 갖추었다면 이러한 타입이 작동하는 방식을 유추할 수 있습니다.
  • 문자열 리터럴 타입을 스트링 타입으로 업케스트 할 수 있습니다.
    • 더 엄격하면 서브 타입입니다.
    • 반대는 불가능합니다. (downcast : literal 타입에 string 할당)
let stringLiteral: 'hello' = 'hello';let strStr : string = stringLiteral
  • 속성명과 속성 타입의 교집합이 있는 두 Object type의 경우
    • 한 객체가 다른 객체보다 엄격(추가 속성 보유)하면, 덜 엄격한 객체 타입에 할당할 수 있습니다 (upcast)
    • 반대는 불가능합니다. (downcast) 
type UserWithEmail = {name: string, email: string}
type UserWithoutEmail = {name: string}

type A = UserWithEmail extends UserWithoutEmail ? true : false // true ✅ ⬆️upcast 
  • 또는 비어 있지 않은 개체를 빈 개체에 할당합니다.
    const emptyObject: {} = {foo: 'bar'} // ✅ ⬆️upcast 
    

 

void

마지막으로 이야기하고 싶은 타입이 있습니다. void입니다.

C++와 자바에서 void는 리턴값이 없는 함수의 리턴 타입을 의미합니다.
(In many other languages, such as C++, void is used as the a function return type that means that function doesn't return)

그렇다면 TypeScript의 void 타입은 무엇입니까?
TypeScript의 void는 undefined의 상위 타입입니다.
TypeScript를 사용하면 undefined를 void(upcaset)에 할당할 수 있지만 그 반대는(downcast) 할당할 수 없습니다.
void는 undefined의 상위 유형입니다.
이것은 extends 키워드를 통해서도 확인할 수 있습니다:
type A = undefined extends void ? true : false;  // true
type B = void extends undefined ? true : false; // false
void는 우측 표현식을 undefined로 평가하는 javascript 함수입니다.
void 2 // undefined
void(2) // undefined
TypeScript에서 void 타입은 함수의 구현자가 리턴값이 호출자에게 유용하지 않을 것이라는 점을 제외하고는 반환 타입에 대해 어떠한 보장도 하지 않음을 나타내는 데 사용됩니다.
 
이렇게 하면 런타임 시 void 함수가 정의되지 않은 것 이외의 것을 반환할 수 있는 문이 열리지만,
반환되는 것은 호출자가 사용해서는 안 됩니다.
function fn(cb: () => void): void {
    return cb()
}

fn(() => 'string')
문자열 타입은 void의 하위 타입이 아니므로 void를 대체할 수 없어야 하기 때문에 Liskov 치환 원칙을 위반하는 것처럼 보일 수 있습니다. 그러나 프로그램의 정확성(correctness)을 변경하는지 여부의 관점에서 본다면
호출자 함수가 void 함수에서 반환된 값을 가지고 뭔가를 하지 않는 이상, 다른 것을 반환하는 함수로 대체하는 것은 꽤 무해합니다.
그리고 이는 type을 통해 의도한 결과입니다.
(주 : 실제는 string이지만 추론 결과는 void타입인 값을 가지고 뭘 하지 않는 이상은 상관 없으니 허용한다라는 것입니다.)

이것이 TypeScript가 실용적으로 JavaScript가 함수와 함께 작동하는 방식을 보완하는 곳입니다.
JavaScript에서는 반환 값이 무시되는 다양한 상황에서 함수를 재사용하는 것이 매우 일반적입니다.
void 타입에 대한 또 다른 멋진 팁은 함수를 선언할 때 void로 주석을 달 수 있다는 것입니다.

function doSomething(this: void, value: string) {
    this // void
}
이것은 함수 내에서 this를 사용하는 것을 방지합니다.

TypeScript가 암시적 업캐스트를 허용하지 않는 상황 (잉여 속성 체크)

일반적으로 두 가지 상황이 있으며 솔직히 말해서 다음과 같은 상황에 처하는 경우는 매우 드뭅니다.
(주 : 잉여 속성 체크라고도 함)
  • 리터럴 객체를 함수에 직접 전달할 때
function fn(obj: {name: string}) {}

fn({name: 'foo', key: 1}) // ❌ Object literal may only specify known properties, and 'key' does not exist in type '{ name: string; }'
  • 명시적 타입이 있는 변수에 리터럴 개체를 직접 할당할 때
type UserWithEmail = {name: string, email: string}
type UserWithoutEmail = {name: string}

let userB: UserWithoutEmail = {name: 'foo', email: 'foo@gmail.com'} // ❌ Type '{ name: string; email: string; }' is not assignable to type 'UserWithoutEmail'.

다시 2번으로 가서 문제를 풀어봅시다!

참고 :

원문 보기

 

The type hierarchy tree

A reflection on my mental model of TypeScript’s type system

www.zhenghao.io

타입 대수 알아보기

 

타입스크립트의 타입 대수(type argebra)를 통해 타입 오류 분석하기

Type algebra는 TypeScript에서 많이 알려지지 않은 주제이며 TypeScript의 몇 가지 단점을 이해하는 데 필수적인 주제입니다. Algebras (대수) 집합 대수가 |,&에서 모두 분배법칙이 성립하며 타입스크립트

itchallenger.tistory.com

 

반응형