zhenghao type-hierarchy-tree 링크
타입스크립트 공부를 하면서 나름 열심히 이해해보려고 zhenghao님의 블로그 글을 번역해보았습니다. 이해가 부족해 번역이 제대로 되지 않았을 수 있습니다. 만일 이상한 부분이 있거나 더 좋은 방법이 있다면 말씀해주세요! 💪
타입스크립트 타입 시스템에 대한 필자의 멘탈 모델에 대한 고찰
아래의 짧은 타입스크립트 코드를 읽어보자. 그리고 어떤 타입에러가 나올지 생각해보자.
// 1. any and unknown
let stringVariable: string = 'string'
let anyVariable: any
let unknownVariable: unknown
anyVariable = stringVariable
unknownVariable = stringVariable
stringVariable = anyVariable
stringVariable = unknownVariable
// 2. `never`
let stringVariable: string = 'string'
let anyVariable: any
let neverVariable: never
neverVariable = stringVariable
neverVariable = anyVariable
anyVariable = neverVariable
stringVariable = neverVariable
// 3. `void` pt. 1
let undefinedVariable: undefined
let voidVariable: void
let unknownVariable: unknown
voidVariable = undefinedVariable
undefinedVariable = voidVariable
voidVariable = unknownVariable
// 4. `void` pt. 2
function fn(cb: () => void): void {
return cb()
}
fn(() => 'string')
만일 당신이 올바른 답을 스스로 도출해낼 수 있다면 필자는 정말로 감탄할 것이다. 적어도 필자는 1년 넘게 타입스크립트를 작성했음에도 제대로 이해하지 못했다. any, unknown, void, never와 같은 타입을 포함하는 부분에 대해 혼란을 느꼈다.
필자는 필자 스스로 타입이 어떻게 작동하는지에 대한 정확한 멘탈 모달을 갖고있지 않다는 것을 깨달았다. 일관되고 정확한 멘탈 모달 없이 경험이나 직관에 의해 타입스크립트를 사용하다보니 끊임없는 시행착오에 직면할 수밖에 없었다.
이 게시물은 타입스크립트 타입시스템의 멘탈 모달을 정리하고 재구축하기 위한 시도에 대한 것이다.
이 글은 짧은 글이 아니다. 바쁘다면 유형 계층 구조 트리를 살펴보는 섹션으로 바로 이동해도 무방하다.
타입스크립트의 모든 타입은 계층구조의 구성원으로서 자리를 차지한다. 이를 트리와 같은 구조로 시각화할 수 있다. 최소한 부모노드와 자식노드가 있을 것이다. 타입시스템에서는 이와 같은 관계를 슈퍼타입(부모노드)과 서브타입(자식노드)라고 부른다.
superType
|
subType
당신은 상속에 대해(객체지향 프로그래밍의 컨셉 중 하나로서) 친숙할 것이다. 상속은 부모클래스와 자식클래스 사이를 'is-a' 관계로 본다. 부모클래스는 Vehicle
이고 자식 클래스가 Car
이라면, "Car
is Vehicle
" 관계가 된다. 그러나 반대의 경우-"Vehicle
is Car
"-는 해당되지 않는다. 자식 클래스의 인스턴스는 부모 클래스의 인스턴스가 아니다. 이는 상속의 시멘틱한 의미이며, 타입스크립트의 타입 계층에도 해당되는 것이다.
리스코프 치환 이론
에 따르면, Vehicle
의 인스턴스(수퍼타입)는 Cars
의 인스턴스(서브타입)로 대체할 수 있어야 한다. 즉, 수퍼타입에 대해 어떠한 특정 동작을 기대하는 경우 서브타입이 이를 충족해야한다.
결론적으로 타입스크립트에서 타입의 하위 타입 인스턴스를 해당 수퍼타입의 인스턴스로 할당/대체 할 수 있지만, 반대는 불가능하다.
그런데
대체(substitute)
라는 단어가 뒤에 따라오는 전치사에 따라 의미가 극명하게 달라진다. 여기서는 'substitute A with B'라 할때 A 대신 B로 끝나는 것을 의미한다.
수퍼타입/서브타입 관계를 강제하는 두가지 방법이 있다. 첫번째, 정적 타입 언어(자바)에서 사용하는 nominal typing이다. class Foo extends Bar
와 같은 구문을 통해 명시적으로 타입간 관계를 선언하는 경우이다. 두번째, 타입스크립트의 structural typing이다. 구조적 타입은 명시적으로 관계를 나타낼 필요가 없다. Foo 타입의 인스턴스는 Bar 타입에 추가적인 멤버가 있더라도 Bar 타입에 있는 모든 멤버를 포함하고 있는 한 Bar의 서브타입이다.
이 수퍼타입-서브타입의 관계를 구별하는 또다른 방법은 어떤 타입이 더 엄격한지(strict) 확인하는 것이다. {name: string, age: number}
는 {name: string}
보다 엄격하다. 정의되어야 할 요소가 더 많기 때문이다. 그래서 전자가 후자의 서브타입이다.
{name: string}
|
{name: string,
age: number}
타입스크립트의 타입 계층구조 트리를 살펴보기 전에 마지막으로 한가지 더 알아보자.
extends
: 한 타입을 다른 타입으로 확장할 수 있다.type A = string extends unknown? true : false; // true
type B = unknown extends string? true : false; // false
타입 계층구조 트리에 대해 이야기해보자.
타입스크립트에서, 모든 타입들의 수퍼타입이 되는 두가지 타입이 있다. any
, unknown
이다.
이들은 다른 모든 타입을 포괄한다.
[ {any} {unknown} ]-Top level
/ | | \
{number} {string} {boolean} {composite type}
이 그래프는 타입스크립트가 지원하는 모든 타입의 전체 목록은 아니다.
두 타입의 타입캐스트가 있다. 업캐스트와 다운캐스트가 그것이다.
{any} {unknown} supertype
| | ↑
downcast|upcast
(explicit)|(implicit)
↓ | |
{string} subtype
서브타입을 수퍼타입에 할당하는것을 업캐스트라고 한다. 리스코프 치환법칙에 따라 업캐스트는 안전하기 때문에 컴파일러에서 문제 없이 암시적으로 업캐스트를 할 수 있다.
타입스크립트에서 암시적 업캐스트를 허용하지 않는 경우도 있다. 마지막부분에서 이 부분에 대해 설명할 것이다.
업캐스트는 엄격한 서브타입을 일반적인 수퍼타입으로 대체하는 것으로 생각할 수 있다.
예를 들어서 모든 string
타입은 any
유형과 unknown
타입의 서브타입이다. 즉 다음과 같은 할당이 허용된다.
let string: string = 'foo'
let any: any = string // ⬆️upcast
let unknown: unknown = string // ⬆️upcast
반대의 경우가 다운캐스트이다. 일반적인 수퍼타입을 보다 엄격한 서브타입으로 대체한다.
다운캐스트는 안전하지 않고 대부분 자동으로 허용되지 않는다. 예를 들어, string 타입에 any
, unknown
을 할당하는 것은 다운캐스트이다.
let any: any
let unknown: unknown
let stringA: string = any // ⬇️downcast - it is allowed because `any` is different..
let stringB: string = unknown // ❌ ⬇️downcast
string에 unknown을 할당하면 타입스크립트 컴파일러는 에러를 뱉는다. 다운캐스트이기때문에 타입체커를 명시적으로 우회하지 않고는 실행되지 않는다.
그런데 타입스크립트는 string에 any를 할당할 수 있도록 허용한다. 이것은 지금까지 본 이론들과는 모순된다.
여기서 any는 타입스크립트에서 자바스크립트로 빠져나가기 위한 백도어 역할을 한다. any는 자바스크립트의 유연성을 확보하기 위한 타협점이다. 이 예외는 설계상의 결함 때문이 아니라 런타임 언어가 자바스크립트라는 특성 때문에 발생한다.
never
타입은 트리의 최하위 계층이다.
[ {any} {unknown} ]-Top level
/ | | \
{number} {string} {boolean} {composite type}
| | | |
{...} {...} {...} {...}
\ | | /
{ never }-Bottom
대칭적으로, never타입은 상위 타입들(any, unknow)의 anti타입처럼 동작한다. any와 unknown은 모든 값을 받는 반면 never는 모든 타입의 서브타입이므로 모든 타입의 값을 포함해 어떤 값도 받지 않는다.
let any: any
let number: number = 5
let never: never = any // ❌ ⬇️downcast
never = number // ❌ ⬇️downcast
number = never // ✅ ⬆️upcast
당신이 충분히 생각해봤다면, never
가 리스코프 치환 법칙에 따라 수퍼타입(즉, TypeScript의 Type 시스템에 있는 다른 모든 유형)에 할당되거나 대체될 수 있어야 하기 때문에 무한히 많은 타입과 멤버를 가져야한다는 것을 깨달았을 것이다. 예를 들어 never
는 string, number의 서브타입이며, 수퍼타입의 정의에 반하지 않아야 하기 때문에, string, number타입을 never
로 대체한 후에도 프로그램이 올바르게 작동해야한다.
기술적으로 불가능한 일이다. 대신, 타입스크립트는 비어있는 타입(즉, 공집합과 같은 타입) never
를 만들었다. 이는 런타임에서 실제 값을 가질 수 없고, 해당 인스턴스의 프로퍼티에 접근하는 등, 해당 유형으로 아무 작업도 할 수 없는 타입이다. 일반적으로 리턴하지 않는 함수의 반환값을 정의하려는 경우 사용한다.
함수는 여러 이유로 리턴하지 않을 수 있다. 모든 코드 경로에서 예외가 발생할 수도 있고, 이벤트루프처럼 전체 시스템이 종료될 때까지 계속 실행하려는 코드가 있어서 영원히 루프될 수도 있다.
function fnThatNeverReturns(): never {
throw 'It never returns'
}
const number: number = fnThatNeverReturns() // ✅ ⬆️upcast
위의 할당은 언뜻 잘못된 것처럼 보일 수 있다. never
가 빈 타입이라면, 왜 number
타입에 할당이 가능한 것일까? 해당 할당은 함수가 리턴하지 않는다는 것을 컴파일러가 알고있어 number변수에 아무것도 할당되지 않을 것이기 때문이다. 타입은 런타임에 데이터가 올바른지 확인하기 위한 것이다. 런타임에 실제로 할당되지 않고, 컴파일러가 이 사실을 미리 알고있다면 타입은 중요하지 않다.
never타입을 활용하는 또 다른 방법은 호환되지 않는 두 유형을 교차하는 것이다. 예: {x: number} & {x: string}
type Foo = {
name: string,
age: number
}
type Bar = {
name: number,
age: number
}
type Baz = Foo & Bar
const a: Baz = {age: 12, name:'foo'} // ❌ Type 'string' is not assignable to type 'never'
상위타입과 하위타입에 대해 이야기해보았다. 사이의 타입들은 매일 쓰는 보통의 다른 타입들을 말한다.(number, string, boolean, object와 같은 합성타입 등)
let stringLiteral: 'hello' = 'hello'
type UserWithEmail = {name: string, email: string}
type UserWithoutEmail = {name: string}
type A = UserWithEmail extends UserWithoutEmail ? true : false // true ✅ ⬆️upcast
const emptyObject: {} = {foo: 'bar'} // ✅ ⬆️upcast
흔히 사람들이 헷깔려하는 하위타입인 never
그리고 void
에 대해 더 이야기를 하고 싶다.
C++과 같은 많은 다른 언어에서 void는 함수가 리턴하지 않는 것을 의미하는 함수 반환 타입으로 사용된다. 그러나 타입스크립트에서 리턴하지 않는 함수의 경우 올바른 반환 타입은 never
이다.
그렇다면 타입스크립트에서 void
는 무엇일까? void
는 undefined
의 수퍼타입이다. 타입스크립트는 void에 undefined를 할당하는 것을 허용한다(업캐스트). 그러나 반대의 경우는 불가능하다.(다운캐스트)
{any} {unknown} - Top
|
void
|
undefined
|
never - Bottom
extends
키워드를 통해 확인할 수 있다.
type A = undefined extends void ? true : false; // true
type B = void extends undefined ? true : false; // false
또, 자바스크립트에서 void는 다음의 표현식을 undefined로 표현하는 연산자이다. 예:
void 2 === undefined // true
타입스크립트에서 void를 사용하면 함수 구현자가 리턴 타입에 대해 어떠한 보장도 하지 않는다는 것을 나타낸다. 런타임에서 void는 undefined 이외의 다른 값을 반환할 수도 있는 가능성이 생긴다. 그러나 반환된 값은 호출자(caller)에 의해 사용되어서는 안된다.
function fn(cb: () => void): void {
return cb()
}
fn(() => 'string')
언뜻 보기에 string타입은 void의 서브타입이 아니기 때문에 void로 치환할 수 없고 리스코프 치환 법칙에 위배되는 것처럼 느껴진다. 그러나 프로그램의 정확성을 변경하는지 여부에 따른 관점에서 보면 호출 함수가 void함수의 반환 값과 관련이 없는 한 다른 값을 반환하는 함수로 대체해도 무방하다.
이는 타입스크립트가 실용성을 지향하고 자바스크립트가 함수와 작동하는 방식을 보완하는 방식이다. 자바스크립트에서는 리턴값이 무시된 채 다른 상황에서 함수를 재사용하는 경우가 많다.
또다른 void 타입에 대한 멋진 팁은 함수를 선언할 때 this
에 void
를 주석으로 수 있다는 것이다.
function doSomething(this: void, value: string) {
this // void
}
이렇게 하면 함수 내에서 this
를 사용하는 것을 방지할 수 있다.
일반적으로 두가지 경우가 있다. 솔직히 말하자면 이런 상황에 처하는 경우는 매우 드물 것이다.
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'.