타입스크립트의 타입 계층을 통해 타입 호환성을 알아봅시다.
1. 타입 호환성은 뭔가요?
타입스크립트에는 다양한 타입이 존재하는데, 어떠한 타입 변수에 다른 타입 데이터(값)이 할당 가능하면 타입이 호환된다고 합니다.
타입스크립트는, 구조적 타이핑을 허용하기에, 클래스 명이 다르면 하위 구조가 완전 같아도 호환이 불가능한 자바의 명목 타이핑과 다른 동작을 보여줍니다.
기본적으로 타입 호환성은 업캐스팅 가능 여부로 판단합니다.
이 게시물에서는 타입스크립트 기본 타입 간의 계층 구조를 이해하며, 타입 호환성을 알아봅니다.
아래 표가 어렵다면 해당 문단을 생략하고 아래 문단부터 읽으시면 됩니다.
또한 집합을 의미하는 타입(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 타입은 어디에나 할당할 수 있습니다.
- unknown 타입 변수에는 어떤것이나 할당할 수 있지만, never 타입 변수에는 어떤것도 할당할 수 없습니다. (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. 실력 체크
// 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. 타입 계층 트리를 살펴보기 전에
즉 Vehicle(차량)는 car(자동차)가 아닙니다.
Liskov 대체 원리는 박사 학위를 위해 작성된 30년 전의 논문에서 나온 개념입니다.
하나의 블로그 게시물에서 다룰 수 없는 수많은 뉘앙스가 있습니다.
명목 / 구조적 타이핑(nominal and structural typing)
할당 가능성을 파악하는 두가지 방법 (two ways of checking assignability/substitutability)
- 타입 캐스트: 한 타입의 변수를 다른 타입의 변수에 할당하여 타입 오류가 발생하는지 확인할 수 있습니다. 나중에 자세히 설명합니다.
- 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)
업캐스트 & 다운캐스트 (upcast & downcast)
TypeScript가 암시적 업캐스트를 허용하지 않는 예외가 있습니다. 포스팅 말미에 다루도록 하겠습니다.
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
unknown을 문자열 타입에 할당하면 TypeScript 컴파일러는 타입 오류를 표시합니다.
이는 타입 검사기를 명시적으로 우회하지 않고는 수행할 수 없는 다운캐스트이기 때문입니다.
그러나 TypeScript는 우리의 이론과 모순되는 것처럼 보이는 문자열 타입에 any를 할당하는 것을 기꺼이 허용합니다.
주 : 지금까지, unknown은 전체집합, any는 백도어(escape hatch)정도로 보는게 좋습니다.
트리의 밑바닥(the bottom of the tree)
let any: any
let number: number = 5
let never: never = any // ❌ ⬇️downcast
never = number // ❌ ⬇️downcast
number = never // ✅ ⬆️upcast
표준 사용 사례 (canonical usecase) : never는 는 값을 리턴하면 안되는 함수의 리턴 값을 표시하는데 사용됩니다.
함수는 여러 가지 이유로 반환되지 않을 수 있습니다.
모든 코드 경로에서 예외를 throw할 수 있고
이벤트 루프와 같이 전체 시스템이 종료될 때까지 계속 실행하려는 코드가 있기 때문에 영원히 루프할 수 있습니다.
이 모든 시나리오는 유효합니다.
function fnThatNeverReturns(): never {
throw 'It never returns'
}
const number: number = fnThatNeverReturns() // ✅ ⬆️upcast
교집합 속성이 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
보충설명
type Foo = {
name: string,
age: number
}
type Bar = {
name: number,
age: number
}
type Baz = Foo & Bar // {name: never, age: number}
type Foo = {
name: boolean,
age: number
}
type Bar = {
name: number,
age: number
}
type Baz = Foo & Bar // never
(주 : 둘 다 못써먹는 타입인건 동일함)
트리 꼭대기와 밑바닥 사이 타입들
- 문자열 리터럴 타입을 스트링 타입으로 업케스트 할 수 있습니다.
- 더 엄격하면 서브 타입입니다.
- 반대는 불가능합니다. (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)
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
function fn(cb: () => void): void {
return cb()
}
fn(() => 'string')
이것이 TypeScript가 실용적으로 JavaScript가 함수와 함께 작동하는 방식을 보완하는 곳입니다.
JavaScript에서는 반환 값이 무시되는 다양한 상황에서 함수를 재사용하는 것이 매우 일반적입니다.
void 타입에 대한 또 다른 멋진 팁은 함수를 선언할 때 void로 주석을 달 수 있다는 것입니다.
function doSomething(this: void, value: string) {
this // void
}
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번으로 가서 문제를 풀어봅시다!
참고 :
'FrontEnd' 카테고리의 다른 글
타입스크립트 함수의 시그니처와 리스코프 치환 원칙 (0) | 2022.04.09 |
---|---|
타입스크립트의 타입 대수(type argebra)를 통해 타입 오류 분석하기 (0) | 2022.03.27 |
아폴로 클라이언트로 알아보는 클라이언트 아키텍처 [Apollo Client & Client-side Architecture Basics] (0) | 2022.03.27 |
타입스크립트의 타입 불건전성에 대하여 2 : 공식 예제 코드 살펴보기 (0) | 2022.02.27 |
타입스크립트의 타입 불건전성에 대하여 1 : 타입시스템의 한계 (0) | 2022.02.25 |