53. 타입스크립트 기능보다는 ECMAScript 기능을 사용하기
TC39(자바스크립트 표준 기구)는 내장 기능을 추가해왔다.
그러면서 타입스크립트 초기 버전과의 호환성 문제가 발생했다.
타입스크립트는 자바스크립트의 신규 기능을 그대로 채택하고 타입스크립트 초기 버전과의 호환성을 포기했다.
그렇게 TC39는 런타임 기능을 발전시키고, 타입스크립트 팀은 타입 기능만 발전시킨다는 원칙을 세우고 지켜오고 있다.
이 원칙이 세워지기 전에 이미 사용되고 있던,
타입 공간과 값 공간의 경계를 혼란스럽게 만드는, 피해야 하는 기능을 살펴보자.
다음 기능들으 ㅣ경우에는 타입 정보를 제거한다고 자바스크립트가 되지는 않는다.
1. 열거형(enum)
enum Flavor {
VANILLA = 0,
CHOCOLATE = 1,
STRAWBERRY = 2,
}
단순히 값을 나열하는 것보다는 좋은 방법이다.
그러나 타입스크립트의 열거형은 상황에 따라 다르게 동작한다.
- 숫자 열거형에 0, 1, 2 외의 다른 숫자가 할당되면 매우 위험하다.
- 상수 열거형은 보통의 열거형과 달리 런타임에 완전히 제거된다.
const enum Flavor로 바꾸면, 컴파일러는 Flavor.CHOCOLATE을 0으로 바꾼다. - preserveConstEnums 플래그를 설정한 상태의 상수 열거형은 보통의 열거형처럼 런타임 코드에 정보를 유지한다.
- 문자열 열거형은 런타임의 타입 안전성과 투명성을 제공한다.
그러나 다른 타입과 달리 구조적 타이핑이 아닌 명목적 타이핑을 사용한다.
일반적인 타입들이 할당 가능성을 체크하기 위해 구조적 타이핑을 사용하는 것과 달리, 문자열 열거형은 명목적 타이핑을 사용한다.
const enum Flavor {
VANILLA = "vanilla",
CHOCOLATE = "chocolate",
STRAWBERRY = "strawberry",
}
let flavor = Flavor.CHOCOLATE;
flavor = "strawberry";
// Type '"strawberry"' is not assignable to type 'Flavor'.
명목적 타이핑은 라이브러리를 공개할 때 필요하다.
function scoop(flavor: Flavor) {}
scoop("vanilla");
// Argument of type '"vanilla"' is not assignable to parameter of type 'Flavor'.
Flavor는 런타임 시점에는 문자열이기 때문에, 자바스크립트에서는 오류가 나지 않지만 타입스크립트에서는 오류가 발생한다.
타입스크립트에서는 열거형을 임포트하고 문자열 대신 사용해야 한다.
import { Flavor } from "ice-cream";
scoop(Flavor.VANILLA);
자바스크립트와 타입스크립트에서 동작이 다르기 때문에 문자열 열거형은 사용하지 않는 게 좋다.
열거형 대신 리터럴 타입의 유니온을 사용하자.
type Flavor = "vanilla" | "chocolate" | "strawberry";
let Flavor: Flavor = "chocolate";
리터럴 타입의 유니온은 열거형만큼 안전하며 자바스크립트와 호환되고 자동완성 기능도 사용할 수 있다.
2. 매개변수 속성
클래스 초기화 시 속성 할당을 위해 생성자의 매개변수를 사용한다.
class Person {
name: string;
constructor(name: string) {
this.name = name;
}
}
타입스크립트로 더 간결하게 쓸 수 있다.
class Person {
constructor(public name: string) {}
}
public name은 매개변수 속성이라고 불리며, 멤버 변수로 선언하는 것과 동일하게 동작한다.
문제점은 다음과 같다.
- 타입스크립트 컴파일은 일반적으로 코드가 줄어들지만, 매개변수 속성은 코드가 늘어나는 문법이다.
- 매개변수 속성이 런타임에 실제로 사용되지만, 타입스크립트 관점에서는 사용되지 않는 것처럼 보인다.
- 매개변수 속성과 일반 속성을 섞어서 사용하면 클래스 설계가 혼란스러워진다.
class Person {
first: string;
last: string;
constructor(public name: string) {
[this.first, this.last] = name.split(" ");
}
}
name은 매개변수 속성에 있어서 일관성이 없다.
클래스에 매개변수 속성만 존재한다면 클래스 대신 인터페이스로 만들고 객체 리터럴을 사용하는 것이 좋다.
쓸 거면 둘 중 한 가지만 쓰자.
3. 네임스페이스와 트리플 슬래시 임포트
es6 이전에는 자바스크립트에 공식적인 모듈 시스템이 없었다.
타입스크립트는 module 키워드와 트리플 슬래시 임포트를 사용했다.
모듈 시스템이 도입된 후, 타입스크립트는 충돌을 피하기 위해 module과 같은 기능을 하는 namespace 키워드를 추가했다.
namespace foo {
function bar() {}
}
/// <reference path="other.ts"/>
foo.bar();
이는 호환성을 위해 남아 있을 뿐, import/export를 사용해야 한다.
4. 데코레이터
데코레이터는 클래스, 메서드, 속성에 에너테이션을 붙이거나 기능을 추가하는 데 사용할 수 있다.
클래스 메서드가 호출될 때마다 로그를 남기는 예시를 보자.
class Greeter {
@logged
greet() {
return "hello";
}
}
function logged(target: any, name: string, descriptor: PropertyDescriptor) {
const fn = target[name];
descriptor.value = function () {
console.log(`Calling ${name}`);
return fn.apply(this, arguments);
};
}
데코레이터는 처음에 앵귤러를 지원하기 위해 추가된 기능이다.
tsconfig.json에 experimentalDecorators 속성을 설정하고 사용해야 한다.
현재까지도 표준화가 완료되지 않았기 때문에, 애너테이션이 필요한 프레임워크를 사용하는 게 아니라면 사용하지 않는 게 좋다.
54. 객체를 순회하는 노하우
객체를 순회할 때, 키가 어떤 타입인지 정확히 파악하고 있다면 keyof T와 for-in 루프를 사용하자.
interface AB {
a: string;
b: number;
}
function foo(ab: AB) {
for (const k in ab) {
const v = ab[k]; // 오류. 엘리먼트는 암시적으로 'any' 타입입니다.
}
}
정상적으로 실행되지만 컴파일 오류가 발생한다.
k의 타입은 string인 반면, obj 객체에는 'one', 'two', 'three' 세 개의 키만 존재하기 때문에
k와 obj 객체의 키 타입이 서로 다르게 추론되어 오류가 발생한 것이다.
오류가 잘못된 것 같지만, 그렇지 않다.
const x = { a: "a", b: "b", c: new Date() };
foo(x);
foo 함수는 AB 타입에 할당 가능한 어떠한 값이든 매개변수로 허용한다.
따라서 a, b 외에 다른 속성이 존재할 수 있기 때문에 타입스크립트는 키를 string으로 선택한다.
이는 keyof 키워드로 해결할 수 있다. 하지만 또 다른 문제점을 가진다.
let k: keyof AB;
for (k in ab) { // k: "a" | "b"
const v = ab[k]; // string | number
}
v도 같이 타입이 한정되었다. 범위가 너무 좁다. 이는 잘못된 추론이다.
단지 객체의 키와 값을 순회하고 싶다면 Object.entries를 사용하면 된다.
function foo(ab: AB) {
for (const [k, v] of Object.entries(ab)) {
k; // string
v; // any
}
}
객체를 다룰 때는 항상 '프로토타입 오염'의 가능성을 염두에 두어야 한다.
Object.prototype.z = 3;
const obj = { x: 1, y: 2 };
for (const k in obj) {
console.log(k);
} // x y z
절대로 Object.prototype에 순회 가능한 속성을 추가하면 안 된다.
for-in 루프에서 k가 string 키를 가지게 된다면 프로토타입 오염의 가능성을 의심해야 한다.
55. DOM 계층 구조 이해하기
DOM에는 타입 계층 구조가 있다.
타입 | 예시 |
EventTArget | window, XMLHttpRequest |
Node | document, Text, Comment |
Element | HTMLElement, SVGElement |
HTMLElement | <i>, <b> |
HTMLButtonElement | <button> |
HTMLElement는 Element의 서브타입이고, Element는 Node의 서브타입이고, Node는 EventTarget의 서브타입이다.
1. EventTarget
EventTarget은 DOM 타입 중 가장 추상화된 타입이다.
const targetEl = e.currentTarget;
targetEl.classList.add('dragging')
// 객체가 'null'인 것 같습니다.
// 'EventTarget' 형식에 'classList' 속성이 없습니다.
Event의 currentTarget 속성의 타입은 EventTarget | null이다. 그래서 오류가 발생했다.
e.currentTarget은 실제로 HTMLElement 타입이지만, 타입 관점에서는 window나 XMLHttpRequest가 될 수도 있다.
2. Node
HTMLElement는 children과 childNodes 속성을 가진다.
children은 자식 엘리먼트를 포함하는 배열과 유사한 구조인 HTMLCollection이다.
childNodes는 배열과 유사한 Node의 컬렉션인 NodeList이다.
childNodes는 엘리먼트뿐만 아니라 텍스트 조각과 주석까지도 포함한다.
3. element, HTMLElement
<html>은 HTMLElement이고 <svg>는 SVGSvgElement이다.
4. HTMLxxxElement
해당 형태의 특정 엘리먼트들은 자신만의 고유한 속성을 가진다.
이런 속성에 접근하려면, 타입 정보 역시 실제 엘리먼트 타입이어야 하므로 상당히 구체적으로 타입을 지정해야 한다.
document.getelementById로는 항상 HTMLElement 타입으로 가져오게 된다.
일반적으로 타입 단언문은 지양해야 하지만, DOM 관련해서는 우리가 타입을 더 잘 알기 때문에 써도 된다.
또한 null일 가능성이 없다면 null 아님 단언문(!)을 쓸 수도 있다.
Event 타입에도 별도의 계층 구조가 있다. Event는 가장 추상화된 이벤트다.
function handleDrag(eDown: Event) {
eDown.clientX; // 'Event'에 'clientX' 속성이 없습니다.
}
- UIEvent: 모든 종류의 사용자 인터페이스 이벤트
- MouseEvent: 마우스로부터 발생되는 이벤트
- TouchEvent: 모바일 기기의 터치 이벤트
- WheelEvent: 스크롤 휠로부터 발생되는 이벤트
- KeyboardEvent: 키 누름 이벤트
clientX는 보다 구체적인 MouseEvent 타입에 있기 때문에 오류가 발생했다.
mousedown 이벤트 핸들러를 인라인 함수로 만들면 타입스크립트는 더 많은 문맥 정보를 사용할 수 있다.
function addDragHandler(el: HTMLElement) {
el.addEventListener("mousedown", (eDown) => {
eDown.clientX; // 정상
});
}
56. 정보를 감추는 목적으로 private 사용하지 않기
타입스크립트에서는 public, protected, private 접근 제어자를 사용할 수 있다.
이는 공개 규칙을 강제할 수 있는 것으로 오해할 수 있다.
접근 제어자 또한 타입스크립트 키워드이기 때문에 컴파일 후에는 제거된다.
단지 컴파일 시점에만 오류를 표시해 줄 뿐이다. 런타임에는 아무런 효력이 없다.
class PasswordChecker {
private passwordHash: number;
constructor(passwordHash: number) {
this.passwordHash = passwordHash;
}
checkPassword(password: string) {
return hash(password) === this.passwordHash;
}
}
const checker = new PasswordChecker(hash("s3cret"));
checker.passwordHash; // 오류
(checker as any).passwordHash; // 정상
즉, 정보를 감추기 위해 private을 사용하면 안 된다.
자바스크립트에서 정보를 숨기기 위해 가장 효과적인 방법은 클로저다.
다음 코드처럼 생성자에서 클로저를 만들어 낼 수 있다.
declare function hash(text: string): number;
class PasswordChecker {
checkPassword: (password: string) => boolean;
constructor(passwordHash: number) {
this.checkPassword = (password: string) => {
return hash(password) === passwordHash;
};
}
}
const checker = new PasswordChecker(hash("s3cret"));
checker.checkPassword("s3cret");
PasswordChecker의 생성자 외부에서 passwordHash 변수에 접근할 수 없기 때문에 정보를 숨길 수 있다.
하지만 passwordHash에 접근해야 하는 메서드 역시 생성자 내부에 정의되어야 하며,
메서드 정의가 생성자 내부에 존재하게 되면 인스턴스를 생성할 때마다 각 메서드의 복사본이 생성되기 때문에 메모리를 낭비하게 된다.
또한 동일한 클래스로부터 생성된 인스턴스라고 하더라고 서로의 비공개 데이터에 접근하는 것이 불가능하다.
현재 표준화가 진행 중인 비공개 필드 기능을 사용할 수도 있다.
접두사로 #를 붙여서 타입 체크와 런타임 모두에서 비공개로 만들 수 있다.
class PasswordChecker {
#passwordHash: number;
constructor(passwordHash: number) {
this.#passwordHash = passwordHash;
}
checkPassword(password: string) {
return hash(password) === this.#passwordHash;
}
}
const checker = new PasswordChecker(hash("s3cret"));
checker.#passwordHash; // 오류
(checker as any).#passwordHash; // 오류
비공개 필드 기능은 클래스 외부에서 접근할 수 없지만,
클로저와 다르게 클래스 메서드나 동일한 클래스의 개별 인스턴스끼리는 접근이 가능하다.
컴파일하면 WeapMap을 사용한 구현으로 대체된다.
57. 소스맵을 사용하여 타입스크립트 디버깅하기
타입스크립트 코드를 실행한다는 것은 타입스크립트 컴파일러가 생성한 자바스크립트 코드를 실행한다는 것이다.
디버거는 런타임에 동작하며, 디버깅을 하면 보게 되는 자바스크립트 코드는 매우 복잡하다.
소스맵은 변환된 코드의 위치와 심벌들을 원본 코드의 원래 위치와 심벌들로 매핑한다.
tsconfig.json에서 sourcceMap 옵션을 켜면, 컴파일 후 각 .ts 파일에 대해 .js와 .js.map 두 개의 파일이 생성된다.
소스맵이 같이 있으면, 브라우저의 디버거에서 새로운 index.ts 파일이 나타난다.
소스맵이 최종적으로 변환된 코드에 완전히 매핑되었는지 확인하고,
소스맵에 원본 코드가 공개되지 않도록 설정을 확인하자.