6. 편집기를 사용하여 타입 시스템 탐색하기
타입스크립트를 설치하면 다음 두 가지를 실행할 수 있다.
- 타입스크립트 컴파일러(tsc)
- 단독으로 실행할 수 있는 타입스크립트 서버(tsserver)
타입스크립트 서버 또한 '언어' 서비스를 제공한다는 점에서 중요하다.
언어 서비스에는 코드 자동 완성, 명세 검사, 검색, 리팩터링이 포함된다.
편집기에서 타입스크립트 언어 서비스를 적극 활용해야 한다.
편집기를 사용하면 어떻게 타입 시스템이 동작하는지, 타입스크립트가 어떻게 타입을 추론하는지 개념을 잡을 수 있다.
타입스크립트가 동작을 어떻게 모델링하는지 알기 위해 타입 선언 파일을 찾아보는 방법을 터득해야 한다.
7. 타입이 값들의 집합이라고 생각하기
변수에는 다양한 종류의 값을 할당할 수 있고, 코드가 실행되기 전, 즉 타입스크립트가 오류를 체크하는 순간 '타입'을 가지게 된다.
'할당 가능한 값들의 집합'이 타입이라고 생각하면 된다. 이 집합은 타입의 '범위'라고 부르기도 한다.
이 집합은 유한(boolean, literal)하거나 무한(number, string)하다.
가장 작은 집합은 아무 값도 포함하지 않는 공집합이며, 타입스크립트에서는 never 타입이다.
아무런 값도 할당할 수 없다.
const x: never = 12;
// '12' 형식은 'never' 형식에 할당할 수 없습니다.
그 다음으로 작은 집합은 한 가지 값만 포함하는 타입이다.
타입스크립트에서 unit 타입이라고도 불리는 literal 타입이다.
type A = 'A';
type B = 'B';
type Twelve = 12;
두 개 이상으로 묶으려면 union 타입을 사용한다. union 타입은 값 집합들의 합집합을 일컫는다.
type AB = 'A' | 'B';
type AB12 = 'A' | 'B' | 12;
실제 다루게 되는 타입은 대부분 범위가 무한대이다.
범위가 무한대인 타입은 원소들을 일일이 추가해서 만든 걸로 생각할 수도 있다.
type Int = 1 | 2 | 3 | 4 | 5 // | ...
또는 다음처럼 원소를 서술하는 방법도 있다.
interface Identified {
id: string;
}
어떤 객체가 string으로 할당 가능한 id 속성을 가지고 있다면 그 객체는 Identified이다.
값의 집합을 타입이라고 생각해보자.
interface Person {
name: string;
}
interface Lifespan {
birth: Date;
death?: Date;
}
type PersonSpan = Person & Lifespan;
& 연산자는 두 타입의 intersection을 계산한다.
인터섹션 타입의 값은 각 타입 내의 속성을 모두 포함하는 것이 일반적인 규칙이다.
그래서 Person과 Lifespan을 두 다 가지는 값은 intersection 타입에 속하게 된다.
const ps: PersonSpan = {
name: "Alan Turing",
birth: new Date("1012/06/23"),
death: new Date("1954/06/07"),
};
두 인터페이스의 유니온에서는 그렇지 않다.
type K = keyof (Person | Lifespan); // 타입이 never
조금 더 명확히 써 보자면 다음과 같다.
keyof (A&B) = (keyof A) | (keyof B)
keyof (A|B) = (keyof A) & (keyof B)
조금 더 일반적으로 PersonSpan 타입을 선언하는 방법은 extends 키워드를 쓰는 것이다.
interface Person {
name: string;
}
interface PersonSpan extends Person {
birth: Date;
death?: Date;
}
extends의 의미는 '~에 할당 가능한'과 비슷하게, '~의 부분 집합'이라는 의미로 받아들일 수 있다.
PersonSpan은 Person의 서브타입이다. (클래스 관점에서는 서브클래스)
리터럴 타입과 유니온 타입과 비교했을 때 집합 스타일이 훨씬 직관적이다.
extends 키워드는 제너릭 타입에서 한정자로도 쓰인다.
function getKey<K extends string>(val: any, key: K) {}
getKey({}, "x");
getKey({}, Math.random() < 0.5 ? "a" : "b");
getKey({}, document.title);
K는 string의 부분 집합 범위를 가지는 어떠한 타입이 된다.
이 타입은 string 리터럴 타입, string 리터럴 타입의 유니온, string 자신을 포함한다.
8. 타입 공간과 값 공간의 심벌 구분하기
타입스크립트 코드를 읽을 때 타입인지 값인지 구분해야 한다.
type, interface 뒤의 심벌은 타입이고, const, let 뒤의 심벌은 값이다.
타입 선언(:), 단언문(as) 뒤의 심벌은 타입이고, = 뒤의 모든 것은 값이다.
모든 값은 타입을 가지지만, 타입은 값을 가지지 않는다.
class, enum 같은 키워드는 타입과 값 두 가지 모두 가능한 예약어다.
클래스가 타입으로 쓰일 때는 형태(속성과 메서드)가 사용되고, 값으로 쓰일 때는 생성자가 사용된다.
"foo"는 문자열 리터럴일 수도, 문자열 리터럴 타입일 수도 있다.
typeof, this 등 타입 공간과 값 공간에서 다른 목적으로 사용될 수도 있다.
typeof의 경우 타입의 관점에서는 값을 읽어서 타입스크립트 타입을 반환하고,
값의 관점에서는 자바스크립트 런타임의 typeof 연산자가 된다.
(런타임 타입: string, number, boolean, undefined, object, function)
interface Person {
first: string;
last: string;
}
const p: Person;
type T = type p; // 타입은 Person
const v = type p; // 값은 "object"
따라서 class에 대한 typof도 상황에 따라 다르다.
class Cylinder {}
const v = typeof Cylinder; // 값이 "function"
type T = typeof Cylinder; // 타입이 typeof Cylinder
declare let fn: T;
const c = new fn(); // 타입이 Cylinder
따라서 InstanceType 제너릭을 사용해 생성자 타입과 인스턴스 타입을 전환할 수 있다.
type C = InstanceType<typeof Cylinder>; // 타입이 Cylinder
obj['field']와 obj.field는 값이 동일하더라도 타입이 다를 수 있다.
타입의 속성을 얻을 때는 속성 접근자 []를 사용해야 한다.
const first: Person["first"] = p.first;
두 공간 사이에서 다른 의미를 가지는 코드 패턴들
- this는 값에서는 자바스크립트의 this 키워드, 타입에서는 다형성 this라 불리는 this의 타입스크립트 타입
서브클래스의 메서드 체인을 구현할 때 유용 - &와 |는 값에서는 비트연산. 타입에서는 인터섹션과 유니온
- const는 새 변수를 선언하지만, as const는 리터럴 또는 리터럴 표현식의 추론된 타입을 바꿈
- extends는 서브클래스 / 서브타입 / 제너릭 타입의 한정자를 정의
- in은 루프 또는 mapped 타입에 등장
자바스크립트에서는 객체 내 각 속성을 로컬 변수로 만드는 구조 분해 할당이 가능한데,
타입스크립트에서 할 경우 타입과 값을 구분해서 해주어야 한다.
9. 타입 단언보다는 타입 선언을 사용하기
타입 단언(as Type)보다 타입 선언(: Type)을 사용해야 한다.
타입 선언은 해당 값이 선언된 타입임을 명시하는 반면, 타입 단언은 타입스크립트가 추론한 타입이 있더라고 해당 타입으로 간주한다.
타입 선언은 할당되는 값이 해당 인터페이스를 만족하는지 검사하지만, 타입 단언은 강제로 타입을 지정하므로 오류를 무시한다.
타입 단언의 경우 잉여 속성 체크도 적용되지 않는다.
화살표 함수의 타입 선언은 추론된 타입이 모호할 때가 있다.
type Person = { name: string };
const people = ["alice", "bob", "jan"].map((name) => ({ name }));
// Person[]을 원했지만 { name: string; }[]
화살표 함수 안에서 타입과 함께 변수를 선언하는 것이 가장 직관적이다.
const people = ["alice", "bob", "jan"].map((name) => {
const person: Person = { name };
return person;
});
함수의 반환 타입을 명시해줄 수도 있다.
const people = ["alice", "bob", "jan"].map((name): Person => ({ name }));
다음 코드는 최종적으로 원하는 타입을 직접 명시하고, 타입스크립트 코드가 할당문의 유효성을 검사하게 한다.
const people: Person[] = ["alice", "bob", "jan"].map(
(name): Person => ({ name })
);
타입스크립트보다 타입 정보를 더 잘 알고 있는 상황에는 타입 단언을 써야 한다.
대표적으로 DOM 엘리먼트가 있다. 타입스크립트는 DOM에 접근할 수 없기 때문에 엘리먼트의 타입을 모른다.
해당 엘리먼트가 null이 아니라고 확신할 수 있으면 ! 단언문을 쓸 수도 있다.
10. 객체 레퍼 타입 피하기
자바스크립트에는 기본형 값들에 대한 타입 string, number, boolean, null, undefined, symbol, bigint가 있다.
기본형들은 불변이며 메서드를 가지지 않는다는 점에서 객체와 구분되는데, charAt 등 메서드를 가지고 있는 것처럼 보인다.
string 기본형에는 메서드가 없지만, 자바스크립트에는 메서드를 가지는 String 객체 타입이 정의되어 있다.
자바스크립트는 기본형과 객체 타입을 서로 자유롭게 변환한다.
string 기본형에 메서드를 사용할 때, 자바스크립트는 기본형을 String 객체로 래핑하고, 메서드를 호출하고, 래핑한 객체를 버린다.
(null과 undefined를 제외하고 객체 래퍼 타입을 가진다)
const originalCharAt = String.prototype.charAt;
String.prototype.charAt = function (pos) {
console.log(this, typeof this, pos);
// [String: 'primitive'] 'object' 3
return originalCharAt.call(this, pos);
};
console.log("primitive".charAt(3));
// m
메서드 내의 this는 string 기본형이 아닌 String 객체 래퍼이다. String 객체를 직접 생성할 수도 있고, 기본형처럼 동작한다.
하지만 항상 동일하게 동작하는 건 아니다. String 객체는 오직 자기 자신하고만 동일하다.
"hello" === new String("hello"); // false
new String("hello") === new String("hello"); // false
어떤 속성을 기본형에 할당하면 속성이 사라진다.
x = "hello";
x.language = "English";
x.language // undefined
x가 String 객체로 변환된 후 language 속성이 추가되었고, 속성이 추가도니 객체는 버려진 것이다.
래퍼 타입들 덕분에 기본형 값에 메서드를 사용할 수 있고, 정적 메서드도 사용할 수 있다.
타입스크립트는 기본형과 객체 레퍼 타입을 별도로 모델링하기 때문에 직접 생성할 필요가 없다.
기본형 타입은 객체 레퍼에 할당할 수 있기 때문에 타입스크립트는 기본형 타입을 객체 래퍼에 할당하는 선언을 허용하긴 한다.
function isGreeting(phrase: String) {
return ["hello", "good day"].includes(phrase);
}
에러가 발생한다. string은 String에 할당할 수 있지만 String은 string에 할당할 수 없다. string 타입을 사용해야 한다.
11. 잉여 속성 체크의 한계 인지하기
객체 리터럴을 변수에 할당하거나 함수에 매개변수로 전달할 때 잉여 속성 체크가 수행된다.
적용 범위가 매우 제한적이며 오직 객체 리터럴에만 적용된다.
interface Room {
numDoors: number;
ceilingHeightFt: number;
}
const r1: Room = {
numDoors: 1,
ceilingHeightFt: 10,
elephant: "present", // 'Room' 형식에 'elephant'이(가) 없습니다.
};
const obj = {
numDoors: 1,
ceilingHeightFt: 10,
elephant: "present",
};
const r2: Room = obj; // 정상
r1의 경우, 구조적 타입 시스템에서 발생할 수 있는 오류를 잡을 수 있도록 잉여 속성 체크가 수행되었다.
타입스크립트 타입 체커가 수행하는 일반적인 구조적 할당 가능성 체크와는 역할이 다르다.
r2의 경우 임시 변수를 도입하여 잉여 속성 체크를 건너뛰었다. (obj는 객체 리터럴이 아님)
타입의 범위는 아주 넓어질 수 있기 때문에, 순수한 구조적 타입 체커는 이런 종료의 오류를 찾아내지 못한다.
잉여 속성 체크를 이용하면 기본적으로 타입 시스템의 구조적 본질을 해치지 않으면서도 객체 리터럴에 알 수 없는 속성을 허용하지 않음으로써 문제점을 방지할 수 있다. (엄격한 객체 리터럴 체크)
잉여 속성 체크를 원치 않는다면, 인덱스 시그니처를 사용해서 타입스크립트가 추가적인 속성을 예상하도록 할 수 있다.
interface Options {
darkMode?: boolean;
[otherOptions: string]: unknown;
}
const o: Options = { darkMode: true, title: "abc" }; // 정상
공통 속성 체크는 약한 타입에서 동작한다. 임시 변수여도 동작한다.
interface Options {
darkMode?: boolean;
}
const opts = { darkmode: true };
const o: Options = opts; // 'Options' 유형과 공통적인 속성이 없습니다.