이펙티브 타입스크립트
동작 원리의 이해와 구체적인 조언 62가지
2장 타입스크립트의 타입 시스템
아이템 6 편집기를 사용하여 타입 시스템 탐색하기
타입스크립트를 설치하면, 두가지를 실행할 수 있습니다.
- 타입스크립트 컴파일러(tsc)
- 단독으로 실행할 수 있는 타입스크립트 서버(tsserver)
타입스크립트 컴파일러를 실행하는 것이 주된 목적이지만, 타입스크립트 서버 또한 ‘언어 서비스’를 제공한다는 점에서 중요합니다.
언어 서비스
- 코드 자동 완성
- 명세(사양,specification) 검사
- 검색
- 리팩터링
편집기마다 조금씩 다르지만 보통의 경우 마우스 커서를 대면 타입스크립트가 그 타입을 어떻게 판단하고 있는지 확인할 수 있습니다.
num
변수의 타입을 number
라고 직접 지정하지는 않았지만, 타입스크립트는 10
이라는 값을 보고 그 타입을 알아냅니다.
함수의 타입도 추론할 수 있습니다. 주목할 점은 추론된 함수의 반환 타입이 number
라는 것입니다.
function logMessage (message: string | null) {
if(message){
message
}
}
특정 시점에 타입스크립트가 값의 타입을 어떻게 이해하고 있는지 살펴보는 것은 타입 넓히기와 좁히기의 개념을 잡기 위해 꼭 필요한 과정입니다. 조건문의 분기에서 값의 타입이 어떻게 변하는지 살펴보는 것은 타입 시스템을 연마하는 매우 좋은 방법이고 편집기가 도움을 줄 것입니다.
조건문 외부에서 message의 타입은 string | null 이지만 내부에서는 string이다.
언어 서비스는 라이브러리와 라이브러리의 타입 선언을 탐색할 때 도움이 됩니다. 코드 내에서 fetch 함수가 호출되고, 이 함수를 더 알아보길 원한다고 한다면 편집기에서는 ‘Go to Definition(정의로 이동)’ 옵션을 제공합니다
const response = fetch('https://www.dgmunit1.com/')
‘Go to Definition(정의로 이동)’ 옵션을 선택하면 타입스크립트에 포함되어 있는 DOM타입 선언인 lib.dom.d.ts로 이동하고
fetch함수가 promise를 반환하고 두 개의 매개변수를 받는다는 것을 알 수 있습니다. 또 RequestInfo도 동일한 방법으로 탐색을 할 수 있습니다.
이렇게 더 많은 타입을 탐색하다 보면, 타입 선언이 처음에는 이해하기 어렵지만 타입스크립트가 무엇을 하는지, 어떻게 라이브러리가 모델링되었는지, 살펴볼 수 있게 도와줍니다.
메뉴에서 ‘Go to Definition(정의로 이동)’ 옵션을 누르지않고도 마우스를 올리면 편집기는 해당 함수에 대한 정보를 보여줍니다.
요약
- 편집기에서 타입스크립트 언어 서비스를 적극 활용해야 합니다.
- 편집기를 사용하면 어떻게 타입 시스템이 동작하는지, 그리고 타입스크립트가 어떻게 타입을 추론하는지 개념을 잡을 수 있습니다.
- 타입스크립트가 동작을 어떻게 모델링하는지 알기 위해 타입 선언 파일을 찾아보는 방법을 터득해야 합니다.
아이템 7 타입이 값들의 집합이라고 생각하기
never
가장 작은 집합은 아무 값도 포함하지 않는 공집합, 타입스크립트에서는 never 타입입니다. never타입으로 선언된 변수의 범위는 공집합이기 때문에 아무런 값도 할당할 수 없습니다.
리터럴(literal)타입
다음으로 작은 집합은 한 가지 값만 포함하는 타입 타입스크립트에서 유닛(unit)타입이라고도 불리는 리터럴(literal) 타입입니다.
type A = 'A';
type b = 'B';
type Twelve = 12;
유니온(union)타입
두개 혹은 세 개로 묶은 타입 유니온 타입은 값 집합들의 ‘합집합’을 일컫습니다.
type AB = 'A' | 'B';
type AB12 = 'A' | 'B' | 12;
const a: AB = 'A'; // 정상
const c: AB = 'C'; // 'C'형식은 AB형식에 할당할 수 없습니다.
*** 타입체커 집합의 관점에서 타입 체커의 주요역할은 하나의 집합이 다른 집합의 부분 집합인지 검사하는 것이라고 볼 수 있다.
type AB = 'A' | 'B';
type AB12 = 'A' | 'B' | 12;
// OK, {"A", "B"} is a subset of {"A", "B"}:
const ab: AB = Math.random() < 0.5 ? 'A' : 'B';
const ab12: AB12 = ab; // OK, {"A", "B"} 는 {"A", "B", 12} 의 부분 집합
declare let twelve: AB12;
const back: AB = twelve;
// ~~~~ Type 'AB12' 는 'AB' 형식에 할당할 수 없습니다.
// Type '12' 는 'AB' 형식에 할당할 수 없습니다
*** 원소를 서술하는 방법
type Int = 1 | 2 | 3 | 4 | 5 | ...
인터섹션 타입
&연산자는 두 타입의 인터섹션(intersection, 교집합)을 계산합니다.
interface Person {
name: string;
}
interface Lifespan {
birth: Date;
death?: Date;
}
type PersonSpan = Person & Lifespan;
const ps: PersonSpan = {
name: 'Alan Turing',
birth: new Date('1912/06/23'),
death: new Date('1954/06/07'),
}; // 정상
타입 연산자는 인터페이스의 속성이 아닌, 값의 집합(타입의 범위)에 적용됩니다. 그리고 추가적인 속성을 가지는 값도 여전히 그 타입에 속합니다. 그래서 Person과 Lifespan을 둘다 가지는 값은 인터섹션 타입에 속합니다.
당연히 앞의 세가지(name, birth, death)보다 더 많은 속성을 가지는 값도 PersonSpan타입에 속합니다.
인터섹션 타입의 값은 각 타입 내의 속성을 모두 포함하는 것이 일반적인 규칙
type K = keyof (Person | Lifespan) ; // 타입이 never
유니온 타입에 속하는 값은 어떠한 키도 없기 때문에, 유니온에 대한 keyof는 공집합입니다.
keyof (A&B) = (keyof A) | (keyof B)
keyof (A|B) = (keyof A) & (keyof B) // A와 B 겹치는 것이 없으니 never
extend
interface Person {
name: string;
}
interface PersonSpan extends Person {
birth: Date;
death?: Date;
}
타입이 집합이라는 관점에서 Extends의 의미는 ‘~에 할당 가능한’과 비슷하게 ‘~의 부분 집합’이라는 의미로 받아들일 수 있습니다. PersonSpan 타입의 모든 값은 문자열 name 속성을 가져야 합니다. 그리고 birth 속성을 가져야 제대로 된 부분 집합이 됩니다.
interface Vector1D { x: number; }
interface Vector2D extends Vector1D { y: number; }
interface Vector3D extends Vector2D { z: number; }
// 위 코드를 extends 없이 작성하면 이렇게
interface Vector1D { x: number; }
interface Vector2D { x: number; y: number; }
interface Vector3D { x: number; y: number; z: number; }
위 코드의 관계를 그림으로 나타내면 아래와 같습니다. extends없이 인터페이스로 코드를 재작성해 보면
부분집합, 서브타입, 할당 가능성의 관계가 바뀌지 않는다는 걸 명확히 알 수 있습니다.
요약
- 타입을 값의 집합으로 생각하면 이해하기 편합니다.
- 타입스크립트 타입은 엄격한 상속관계가 아니라 겹쳐지는 집합으로 표현됩니다.
- 한객체의 추가적인 속성이 타입 선언에 언급되지 않더라도 그 타입에 속할 수 있습니다
- 타입 연산은 집합의 범위에 적용됩니다.
- ‘A는 B를 상속’, ‘A는 B에 할당 가능’, ‘A는 B의 서브타입’은 ‘A는 B의 부분 집합’과 같은 의미입니다.
아이템 8 타입 공간과 값 공간의 심벌 구분하기
심벌은 이름이 같더라도 속하는 공간에 따라 다른 것을 나타낼 수 있기 때문에 혼란스러울 수 있다.
interface Cylinder { // 타입
radius: number;
height: number;
}
const Cylinder = (radius: number, height: number) => ({radius, height}); // 값
function calculateVolume(shape: unknown) {
if (shape instanceof Cylinder) {
shape.radius
// ~~~~~~ {} 형식에 radius 속성이 없습니다.'
}
}
interface Cylinder의 Cylinder는 타입으로 쓰입니다. const Cylinder에서 Cylinder와 이름은 같지만 값으로 쓰입니다. 이 두 개는 서로 아무런 관련도 없습니다. 상황에 따라서 타입으로 쓰일 수도 있고, 값으로 쓰일 수도 있습니다. 이런 점이 가끔 오류를 발생시킵니다.
instanceof
를 이용해 shape
가 Cylinder
타입인지 체크하려고 했는데
instanceof
는 자바스크립트의 런타임 연산자이고, 값에 대해서 연산을 합니다.
그래서 instanceof Cylinder
는 타입이 아니라 함수를 참조합니다.
type, interface 다음에 나오는 심벌은 ‘타입’, const, let 선언에 쓰이는 것은 ‘값’
type T1 = 'string literal'; // 타입
type T2 = 123; // 타입
const v1 = 'string literal'; // 값
const v2 = 123; // 값
자바스크립트 코드 컴파일 과정에서 타입 정보는 제거되기 때문에, 심벌이 사라진다면 그것은 타입에 해당되는 될 것 입니다. T1, T2는 사라지고 v1,v2만 남아있게됩니다.
const v1 = 'string literal'; // 값
const v2 = 123; // 값
타입선언(:), 단언문(as) 다음에 나오는 심벌은 ‘타입’, = 다음에 나오는 모든 것은 ‘값’
interface Person {
first: string;
last: string;
}
const p: Person = { first: 'Jane', last: 'Jacobs' };
// - --------------------------------- 값
// ------ 타입
두 공간 사이에서 다른 의미를 가지는 코드 패턴
값 | 타입 | |
---|---|---|
this | 자바스크립트의 this | 다형성(polymorphic) this 로 서브 클래스의 메서드 체인을 구현할 때 유용 |
&와ㅣ | AND 와 OR 비트 연산 | 인터섹션과 유니온 |
const | 새 변수 선언 | as const는 리터럴 또는 리터럴 표현식의 추론된 타입을 바꿈 |
extends | class에서 상속할 때 | 서브 클래스 또는 서브타입 또는 제너릭 타입의 한정자 |
in | for in, key in object | 루프 또는 매핑된 타입 |
요약
- 타입스크립트 코드를 읽을 때 타입인지 값인지 구분하는 방법을 터득해야 합니다
- 모든 값은 타입을 가지지만, 타입은 값을 가지지 않습니다. type과 interface같은 키워드는 타입 공간에만 존재합니다
- class나 enum 같은 키워드는 타입과 값 두 가지로 사용될 수 있습니다
- ‘foo’는 문자열 리터럴이거나, 문자열 리터럴 타입일 수 있습니다.
- typeof, this 그리고 많은 다른 연산자들과 키워드들은 타입 공간과 값 공간에서 다른 목적으로 사용될 수 있습니다.
아이템 9 타입 단언보다는 타입 선언을 사용하기
interface Person { name: string };
const alice: Person = { name: 'Alice' }; // 타입은 Person : 타입선언
const bob = { name: 'Bob' } as Person; // 타입은 Person : 타입단언
두가지 방법은 결과가 같아 보이지만 그렇지 않습니다.
첫번째는 alice: Person
은 변수에 ‘타입 선언’을 붙여서 그 값이 선언된 타입임을 명시
두번째는 as Person
은 ‘타입 단언’을 수행합니다. 타입스크립트가 추론한 타입이 있더라도 Person 타입으로 간주
타입 단언보다는 타입선언을 사용하는게 낫다.
interface Person { name: string };
const alice: Person = {}; // 오류, ~~~~~ 'Person' 유형에 필요한 'name'속성이 {} 유형에 없습니다.
const bob = {} as Person; // 오류 없음
타입 선언은 할당되는 값이 해당 인터페이스를 만족하는지 검사합니다. 타입 단언은 강제로 타입을 지정했으니 타입 체커에게 오류를 무시하라고 하는 것 입니다.
타입 단언이 꼭 필요한 경우가 아니라면, 안전성 체크도 되는 타입선언을 사용하는 것이 좋습니다.
화살표 함수의 타입 선언은 추론된 타입이 모호할 때가 있습니다.
다음 코드에 Person 인터페이스를 사용하고 싶다고 가정해보겠습니다.
interface Person { name: string };
const people = ['alice', 'bob', 'jan'].map(name => ({name}));
// Person[]을 원했지만 결과는 { name: string; }[]...
const people = ['alice', 'bob', 'jan'].map(
name => ({name} as Person)
); // Type is Person[]
name에 타입 단언을 쓰면 문제가 해결되는 것 처럼 보였지만
타입 단언을 사용하면 런타임에 문제가 발생하게 됩니다. {} 안에 name이 없어도 오류를 잡아낼 수 없습니다.
const people = ['alice', 'bob', 'jan'].map(name => {
const person: Person = {name};
return person
}); // Type is Person[]
단언문을 쓰지 않고, 다음과 같이 화살표 함수 안에 타입과 함께 변수를 선언하는 것이 가장 직관적입니다.
const people = ['alice', 'bob', 'jan'].map(
(name): Person => ({name})
);
코드를 좀 더 간결하게 보이기 위해 변수 대신 화살표 함수의 반환 타입을 선언
이 코드는 위의 화살표 함수 안에 타입과 함께 변수를 선언한 것과 동일한 체크를 수행합니다.
const people: Person[] = ['alice', 'bob', 'jan'].map(
(name): Person => ({name})
);
최종적으로 원하는 타입을 직접 명시하고, 타입스크립트가 할당문의 유효성을 검사하게 합니다.
타입 단언이 꼭 필요한 경우
- 타입체커가 추론한 타입보다 여러분이 판단한 타입이 더 정확할 때 타입 단언을 사용한다.
- 타입스크립트는 DOM에 접근할 수 없기 때문에 DOM을 조작하는 경우 타입단언을 사용
- *
!
단언문으로 null이 아님을 단언하는 경우
* Null아님 단언 연산자(접미사 !)
TypeScript에서는 명시적인 검사를 하지 않고도 타입에서 null과 undefined를 제거할 수 있는 특별한 구문을 제공합니다. 표현식 뒤에 !를 작성하면 해당 값이 null 또는 undefined가 아니라고 타입 단언하는 것입니다.
다른 타입 단언과 마찬가지로 이 구문은 코드의 런타임 동작을 변화시키지 않으므로, !연산자는 반드시 해당 값이 null 또는 undefined가 아닌 경우에만 사용해야 합니다.
요약
- 타입 단언(as Type)보다는 타입 선언(: Type)을 사용해야 합니다
- 화살표 함수의 반환 타입을 명시하는 방법을 터득해야 합니다
- 타입스크립트보다 타입 정보를 더 잘 알고 있는 상황에서는 타입 단언문과 null아님 단언문을 사용하면 됩니다.
읽고 난 후..
제가 이해할 수 있게 도움을 준 팀 멤버들 최고👍 감사합니다🙏