이펙티브 타입스크립트- 2장 타입 시스템 (1)
아이템 6. 편집기를 사용하여 타입 시스템 탐색하기
타입스크립트를 설치하면 실행 할 수 있는 두 가지
1. 타입스크립트 컴파일러(tsc)
- 주된 목적
2. 타입스크립트 서버(tsserver)
- 언어 서비스(코드 자동완성, 명세검사, 검색, 리팩터링) 제공
- 주로 편집기를 통해 사용됨
언어 서비스는 라이브러리와 라이브러리 타입 선언을 탐색할 때 도움됨
ex) 코드 내에서 fetch함수가 호출 되고 이 함수를 더 알아보길 원한다면
편집기는 'Go to Definition' 옵션을 제공함 -> 해당 옵션을 선택 시 lib.dom.ts 파일에 정의된 fetch함수의 타입 선언을 볼 수 있다.
declare function fetch(
input: RequestInfo, init?: RequestInit
): Promise<Response>;
이를 통해 타입스크립트가 어떻게 동작을 모델링하는 지를 알 수 있음
요약
- 편집기에서 타입스크립트 언어 서비스를 적극 활용해야함
- 편집기 사용 시 타입 시스템 동작 원리 및 타입스크립트가 어떻게 타입을 추론하는 지 개념을 잡을 수 있음
- 타입스크립트가 동작을 어떻게 모델링 하는지 알기 위해 타입 선언 파일을 찾아보는 방법을 터득해야 함
아이템 7. 타입이 값들의 집합이라고 생각하기
모든 변수는 런타입에 고유한 값을 가지지만
코드 실행 전 (타입스크립트가 오류를 체크하는 순간) 에는 '타입'을 가지고 있다.
타입은 '할당 가능한 값들의 집합' 이며 집합은 타입의 '범위'이다.
- 가장 작은 집합은 공집합이고 타입스크립트에서는 never 타입이다.
-> never 타입으로 선언된 변수에 아무런 값도 할당할 수 없음. - 그 다음으로 작은 집합은 한 가지 값만 포함하는 유닛(unit) 타입이라 불리는 리터럴(literal) 타입이다
- 두 개 혹은 세 개로 묶으려면 유니온(union) 타입을 사용한다.
(유니온 타입 = 값들의 합집합) - 세 개 이상의 타입을 묶을 때도 동일하게 | 로 이어주면 된다.
- '할당 가능한' 이라는 문구는 집합의 관점에서 '~의 원소(값과 타입의 관계)' 또는 '~의 부분 집합(두 타입의 관계)을 의미한다.
type A = 'A';
type B = 'B';
type Twelve = 12;
type AB = 'A' | 'B';
type AB12 = 'A' | 'B' | 12;
const a:AB = 'A' // 정상, 'A'는 집합 { 'A', 'B'}의 원소입니다.
const c:AB = 'C' // 오류, 'C' 형식은 'AB'형식에 할당할 수 없습니다.
'C'는 유닛타입, 범위는 단인 값 'C'로 구성되며 AB의 부분집합이 아니므로 오류이다.
-> 집합의 관점에서 타입 체커의 주요 역할은 하나의 집합이 다른 집합의 부분 집합인지 검사하는 것이라 볼 수 있음
const ab:AB = Math.random() < 0.5 ? 'A' : 'B'; // 정상 {'A', 'B'}는 {'A', 'B'}의 부분집합
const ab12:AB12 = ab; // 정상, {'A', 'B'}는 {'A', 'B', 12}의 부분 집합이다.
declare let twelve:AB12;
const back:AB = twelve;
//오류 'AB12'형식은 'AB' 형식에 할당할 수 없습니다.
// '12'형식은 'AB'형식에 할당할 수 없습니다.
Intersection 타입(&), Union 타입(|)
타입의 관점에서는 Union 타입(|)은 합집합, Intersection 타입(&)은 교집합이다,
type A = 'A'
type B = 'B'
type ABType = A | B // 합집합을 나타내는 유니온 타입
type AType = ABType & A // AB타임과 A타입의 공통원소 'A'
하지만 인터섹션 타입(Intersection Type)이 object 타입에 적용될 때 얘기가 달라진다.
interface Person {
name: string;
}
interface LifeSpan {
birth: Date;
death?: Date;
}
type PersonSpan = Person & LifeSpan;
& 연산자는 두 타입의 교집합을 계산하기에 언뜻 보기에 Person과 LifeSpan의 공통점이 없기 때문에 PersonSpan의 타입을 공집합으로 예상하기 쉽다.
하지만 타입 연산자는 인터페이스의 속성이 아닌 값의 집합(타입의 범위)에 적용된다. 그래서 추가적인 속성을 가지는 값도 여전히 그 타입에 속한다.
즉 위 예시에서 name 속성을 가지고 있다면 다른 속성을 가지고 있더라고 Person 타입에 속하고
birth속성을 가지고 있기만 하다면(death 속성은 옵셔널) 다른 속성을 추가로 가지더라도 LifeSpan 타입에 속한다.
const ps:PersonSpan = {
name: 'Alan Turing',
birth: new Date('1912/06/23'),
death: new Date('1954/06/07'),
}; // 정상
즉 위 예와 같이 name, birth, death 속성을 모두 가지는 타입은 당연히 Person 타입에도 속하고 Lifespan 타입에도 속하므로
둘의 intersection인 PersonSpan 타입에 해당하는 것이다.
Union과 Intersection의 함정
다른 예시)
interface Person {
name: string;
age: number;
}
interface Developer {
name: string;
skill: number;
}
type Capt = Person & Developer;
위 코드는 Person 인터페이스의 타입 정의와 Developer 인터페이스의 타입 정의를 & 연산자를 이용하여 합친 후 Capt이라는 타입에 할당한 코드이다. 결과적으로 Capt의 타입은 아래와 같이 정의된다.
{
name: string;
age: number;
skill: string;
}
function introduce(subject: Person | Developer) {
subject.
}
// 타입스크립트 입장에서는 subject가 Person일지 Developer일지 모르기 때문에 참조 에러를 방지하기 위해
공통 프로퍼티인 name만 제공한다.
function introduce(subject: Person & Developer) {
subject.
}
// 위와는 반대로 subject는 Person이면서 동시에 Developer인 타입 즉 두 Interface의 모든 프로퍼티를 지닌 타입이 된다.
타입 객체의 속성 관점에서 보면 (타입 관점과는 반대로) intersection이 두 타입의 속성을 모두 포함하는 합집합,
union은 두 타입의 속성 중 공통인 것만 포함하는 교집합에 가깝다고 볼 수 있다.
예시 참조
- Intersection(교차)는 일반적으로 extends 키워드를 사용해서 상속으로 구현한다.
interface Person {
name: string;
}
interface PersonSpan extends Person {
birth: Date;
death?: Date;
}
요약
- 타입을 값의 집합으로 생각하면 이해하기 편함(이 집합은 유한하거나 무한함)
- 타입스크립트 타입은 엄격한 상속관계가 아니라 겹쳐지는 집합(벤 다이어그램) 으로 표현되며 두 타입은 서로 서브타입이 아니면서도 겹쳐질 수 있다
- 한 객체의 추가적인 속성이 타입 선언에 언급되지 않더라도 그 타입에 속할 수 있음
- 타입 연산은 집합의 범위에 적용된다.
- A와 B의 인터섹션은 A의 범위와 B의 범위의 인터섹션이다.
- 객체 타입에서는 A&B인 값이 A와 B의 속성을 모두 가짐을 의미한다.
- 'A는 B를 상속', 'A는 B에 할당 가능', 'A는 B의 서브타입'은 'A는 B의 부분 집합'과 같은 의미이다.
아이템 8. 타입 공간과 값 공간의 심벌 구분하기
interface Cylinder {
radius: number;
height: number;
}
const Cylinder = (radius: number, height: number) => ({ radius, height });
위의 두 Cylinder는 이름은 같지만 아무 관련이 없다. 이때 상황에 따라 Cylinder 타입으로 쓰일 수 있고 값으로 쓰일 수 있는데 이런 점이 오류을 야기한다. 예시를 보자
function calculateVolume(shape: unknown) {
if (shape instanceof Cylinder) {
shape.radius // ~ '{}' 형식에 'radius' 속성이 없습니다.
}
}
아마도 instanceof를 이용해 shape가 Cylinder타입인지 체크하려고 했을 것이다. 그러나 instanceof는 런타임 연산자로 값에 대한 연산만 한다. 그래서 instnaceof Cylinder는 타입이 아니라 함수를 참조한다.
한 심벌이 타입인지 값인지 언뜻 봐서 알 수 없으나,
일반적으로 type이나 interface 다음에 나오는 심벌은 타입인 반면 const나 let 선언에 쓰이는 것은 값이다.
타입선언(:) 또는 단언문(as) 다음에 나오는 심벌은 타입인 반면 = 다음에 나오는 모든 것은 값이다.
class와 enum은 상황에 따라 타입과 값 두 가지 모두 가능한 예약어이다.
class Cylinder {
radius=1;
height=1;
}
function calculateVolume(shape: unknown) {
if (shape instanceof Cylinder) {
shape // Cylinder 타입
shape.radius // number 타입
}
}
클래스가 타입으로 쓰일 때는 해당 클래스의 형태(속성과 메서드)가 사용되지만 값으로 쓰일 때는 Cylinder라는 생성자 함수가 사용된다
타입에서 쓰일 때와 값에서 쓰일 때 다른 기능을 하는 연산자도 있다. 그 중 하나가 typeof
요약
- 타입스크립트 코드를 읽을 때 타입인지 값인지 구분하는 방법을 터득해야함(타입스크립트 플레이그라운드 활용 추천)
- 모든 값은 타입을 가지지만 타입은 값을 가지지 않음. type과 interface 같은 키워드는 타입공간에만 존재
- class나 enum 같은 키워드는 타입과 값 두 가지로 사용 될 수 있음
- 'foo'는 문자열 리터널이거나, 문자열 리터럴 타입일 수 있음. 차이점을 알고 구별하는 방법을 터득해야함
- typeof, this 그리고 많은 다른 연산자들과 키워드들은 타입공간과 값 공간에서 다른 목적으로 사용될 수 있음
아이템 9. 타입 단언보다는 타입 선언을 사용하기
타입스크립트에서 변수에 값을 할당하고 타입을 부여하는 방법은 아래와 같이 두 가지이다.
interface Person { name: string };
const alice: Person = { name: 'Alice' }; // 타입 선언을 활용
const bob = { name: 'Bob' } as Person; // 타입 단언
이 두 가지 방법은 결과가 같아 보이지만 그렇지 않다.
타입 단언은 타입스크립트가 추론한 타입이 있더라도 Person 타입으로 간주한다.
타입 단언 보다 타입 선언을 사용하는 게 낫다.
const alice: Person = {};
// ~ 'Person' 유형에 필요한 'name' 속성이 '{}' 유형에 없습니다.
const bob = {} as Person; // 오류 없음
const alice: Person = {
name: 'Alice',
occupation: 'Typescript developer'
// ~ 개체 리터럴은 알려진 속성만 지정할 수 있으며
// 'Person' 형식에 'occupation'이(가) 없습니다.
};
const bob = {
name: 'Bob',
occupation: 'Javascript developer'
} as Person; // 오류 없음
타입 선언은 할당되는 값이 해당 인터페이스를 만족하는지 검사하지만 타입 단언은 강제로 타입을 지정했으니 타입 체커에게 오류를 무시하라고 하는 것이다.
아래의 형태 또한 단언문의 형태로 사용하지 않는 것이 좋다.
const bob = <Person>{};
const bob = {} as Person;
타입 단언이 꼭 필요할 때 => 타입 체커가 추론한 타입보다 내가 판단한 타입이 더 정확할 때
예시) DOM 엘리먼트
document.querySelector('#myButton').addEventListener('click', e => {
e.currentTarget // 타입은 EventTarget
const button = e.currentTarget as HTMLButtonElement;
button // 타입은 HTMLButtonElement
});
- 타입스크립트는 DOM에 접근할 수 없기 때문에 #myButton이 버튼 엘리먼트인지 알지 못한다.
이럴 때는 타입 단언문을 쓰는 것이 타당하다.
접미사로 쓰이는 !
접두사로 쓰이는 !는 boolean의 부정문 이나 접미사로 쓰인 !는 그 값이 null이 아니라는 단언문으로 해석된다.
const elNull = document.getElementById('foo'); // 타입은 HTMLElement | null
const el = document.getElementById('foo')!; // 타입은 HTMLElement
타입 단언은 임의의 타입 간에 변환할 수 없으며 서브타입일 경우에만 가능하다.
interface Person { name: string; }
const body = document.body;
const el = body as Person; // 오류
// 'HTMLElement' 형식을 'Person' 형식으로 변환하는 것은
// 형식이 다른 형식과 충분히 겹치지 않기 때문에 실수일 수 있습니다.
// 이것이 의도적인 경우에는 먼저 식을 'unknown'으로 변환하십시오.
이 오류를 해결 하려면 unknown을 사용하면 된다. 모든 타입은 unknown의 서브타입 이기 때문에 unknown이 포함된 단언문은 항상 동작한다.
const el = document.body as unknown as Person; // 정상
요약
- 타입 단언(as Type) 보다 타입 선언(:Type)을 사용해야 한다.
- 화살표 함수의 반환 타입을 명시하는 방법을 터득해야 한다.
- 타입스크립트보다 타입 정보를 더 잘 알고 있는 상황에서는 타입 단언문과 null 아님 단언문을 사용하면 된다.