이펙티브 타입스크립트
"Effective TypeScript" 책의 내용을 바탕으로, 더 깔끔하고 효율적인 TypeScript 코드를 작성하기 위한 핵심 내용을 정리했습니다.
Contents
- 아이템7. 타입이 갑들의 집합이라고 생각하기
 - 아이템 11. 잉여 속성 체크의 한계 인지하기
 - 아이템 17. 변경 관련된 오류 방지를 위해 readonly 사용하기
 - 아이템 32. 유니온의 인터페이스보다는 인터페이스의 유니온을 사용하기
 - 아이템 42. 모르는 타입의 값에는 any 대신 unknown 을 사용하기
 - 아이템 49. 콜백에서 this에 대한 타입 제공하기
 - 아이템 50. 오버로딩 타입보다는 조건부 타입을 사용하기
 - 아이템 56. 정보를 감추는 목적으로 private 사용하지 않기
 

Javascript 에 대한 책들을 읽은 후, Typescript 에 대해서도 깊게 파고들고 싶어서 Typescript 의 이펙티브 시리즈를 구매해서 읽게 되었다. Typescript 의 Best Practice 위주로 정리하는 책이었다. 더 깔끔하고 효율적인 코드를 작성하고, 리팩토링도 더 잘 할 수 있게 도움을 주는 책이었다. 책을 읽고 몰랐더 내용, 그리고 실무에 적용해보면 좋을법한 내용들 위주로 정리해 보았다.
아이템7. 타입이 갑들의 집합이라고 생각하기
런타임에 모든 변수는 자바스크립트 세상의 값으로부터 정해지는 각자의 고유한 값을 가진다. 그러나 코드가 실행되기 전, 즉 타입스크립트가 오류를 체크하는 순간에는 "타입"을 지니고 있다. 타입스크립트의 타입은 할당 가능한 값들의 집합 이라고 생각하면 좋다. 이 집합은 타입의 범위라고 부르기도 한다. 
예를 들어, 정수는 다음과 같은 타입으로 정의할 수 있다.
type Int = 1 | 2 | 3 | 4 | 5 ...
& 연산자는 두 타입의 인터섹션(교집합)을 계산한다.
interface Person {
    name: string;
}
interface Lifespan {
    birth: Date;
    death?: Date;
}
type PersonSpan = Person & Lifespan;
언뜻 보기에 Person 과 Lifespan 인터페이스는 공통으로 가지는 속성이 없기 때문에, PersonSpan 타입을 공집합(never) 타입으로 예상하기 쉽다. 그러나 타입 연산자는 인터페이스의 속성이 아닌, 값의 집합(타입의 범위)에 적용된다. 그리고 추가적인 속성을 가지는 값도 여전히 그 타입에 속한다. 그래서 Person 과 Lifespan 을 둘 다 가지는 값ㅂ은 인터섹션 타입에 속하게 된다. 
하지만, 같은 논리로 | 연산자를 생각해 보면, 다음과 같은 신기한 점을 찾을 수 있다.
type K = keyof (Person | Lifespan); // 타입이 never
앞의 유니온 타입에 속하는 값은 어떠한 키도 없기 때문에, 유니온에 대한 keyof 는 공집합(never) 이어야만 한다. 
이러한 불명확성 때문에 혼란이 오지만, 가장 간단하게 이해하자면 다음과 같다.
keyof (A&B) = (keyof A) | (keyof B);
keyof (A|B) = (keyof A) & (keyof B);
아이템 11. 잉여 속성 체크의 한계 인지하기
타입이 명시된 변수에 객체 리터럴을 할당할 때 타입스크립트는 1. 해당 타입의 속성이 있는지, 그리고 2. 그 외의 속성은 없는지 확인한다.
interface Room {
    numDoors: number;
    ceilingHeightFt: number;
}
const r: Room = {
    numDoors: 1,
    ceilingHeightFt: 10,
    elephant: "present",
    // 개체 리퍼럴은 알려진 속성만 지정할 수 있으며, 
    // "Room" 형식에 "Elephant"이(가) 없습니다.
}
Room 타입에 쌩뚱맞게 elephant 속성이 있는 것이 어색하긴 하지만, 구조적 타이핑 관점(아이템 4) 으로 생각해 보면 오류가 발생하지 않아야 한다. 임시 변수를 도입해 보면 알 수 있는데, obj 객체는 Room 타입에 할당이 가능하다.
const obj = {
    numDoors: 1,
    ceilingHeightFt: 10,
    elephant: "present",
}
const r: Room = obj; // 정상
obj 의 타입은 { numDoors: number; ceilingHeightFt: number;, elephant: string } 으로 추론된다. obj 타입은 Room 타입의 부분 집합을 포함하므로, Room 에 할당 가능하며 타입 체커도 통과한다.
아이템 17. 변경 관련된 오류 방지를 위해 readonly 사용하기
이 아이템은 간단한 지침이지만, 내가 평소에 코드를 짜며 자주 놓치는 부분이어서 정리해 보았다. 다음은 삼각수(triangular number, 1, 1+2, 1+2+3 …) 를 출력하는 코드이다.
function arraySum(arr: number[]) {
    let sum=0, num;
    while((num = arr.pop()) !== undefined) {
        sum += num;
    }
    return sum;
}
위의 함수의 경우, 파라미터로 받은 arr 에 mutation 을 가하면서 여러 위험을 만들 수 있다. 이러한 코드를 원천적으로 변경하기 위해, 파라미터의 타입 자체를 readonly 로 선언할 수 있다.
function arraySum(arr: readonly number[]) {
파라미터를 readonly 로 선언하면 다음과 같은 이점을 얻을 수 있다.
- 타입스크립트는 파라미터가 함수 내에서 변경이 일어나는지 체크한다.
 - 호출하는 쪽에서는 함수가 파라미터를 변경하지 않는다는 보장을 받게 된다.
 - 호출하는 쪽에서 함수에 readonly 배열을 매개변수로 넣을 수 있다.
 
아이템 32. 유니온의 인터페이스보다는 인터페이스의 유니온을 사용하기
유니온 타입의 속성을 가지는 인터페이스를 작성할 때, 혹시 인터페이스의 유니온 타입을 사용하는 게 더 알맞지는 않을지 검토해 봐야 한다. 예를 들어, 다음과 같은 인터페이스를 사용한다고 가정하자.
interface Layer {
    layout: FillLayout | LineLayout | PointLayout;
    paint: FillPaint | LinePaint | PointPaint;
}
위의 모델링은 layout 과 paint 의 타입이 서로 매칭되지 않아도, 타입 시스템에서 문제를 일으키지 않는다. 위의 코드를 좀 더 개선시키기 위한 방법으로, 다음과 같이 인터페이스를 분화하는 방법이 있다.
interface FillLayer {
    layout: FillLayout;
    paint: FillPaint;
}
interface LineLayer {
    layout: LineLayout;
    paint: LinePaint;
}
interface PointLayer {
    layout: PointLayout;
    paint: PointPaint;
}
type Layer = FillLayer | LineLayer | PointLayer;
아이템 42. 모르는 타입의 값에는 any 대신 unknown 을 사용하기
unknown 은 any 대신 쓸 수 있는 타입 시스템이다. unknown 을 이해하기 위해서는 any 의 속성을 먼저 이해해야 한다.
| any | unknown | never |
| --- | --- | --- |
| 어떠한 타입이든 any 타입에 할당 가능하다. | 어떠한 타입이든 unknown 타입에 할당 가능하다. | 어떠한 타입이든 never 에 할당할 수 없다. |
| any 타입은 never 를 제외한 어떠한 타입으로도 할당 가능하다. | unknown 은 오직 unknown 과 any 에만 할당 가능하다. | never 은 어떠한 타입으로도 할당 가능하다. |
먼저, 다음과 같은 YAML 를 파싱하는 함수가 있다고 가정하자.
function parseYAML(yaml: string): any {
    ...
}
const book = parseYAML(`...`);
alert(book.title);     // 오류 없음, 런타임에 "undefined" 경고
book("read");          // 오류 없음, 런타임에 "TypeError: book 은 함수가 아닙니다" Exception 발생
any가 위험한 이유는 다음 두 가지 특징으로부터 비롯된다.
- 어떠한 타입이든 any 타입에 할당 가능하다.
 - any 타입은 어떠한 타입으로도 할당 가능하다.
 
한 집합은 다른 모든 집합의 부분집합이면서 동시에 상위집합이 될 수 없기 때문에, 분명히 any 는 타입 시스템과 상충되는 면을 가지고 있다.
좀 더 Type Safe 한 시스템을 만들기 위해서는, 다음과 같이 unknown 타입을 사용하는 것이 낫다.
function parseYAML(yaml: string): unknown {
    ...
}
const book = parseYAML(`...`);
alert(book.title);     // 개체가 "unknown" 형식입니다.
book("read");          // 개체가 "unknown" 형식입니다.
unknown 은 모든 집합의 상위집합이 되므로, any 를 대체할 수 있으며 타입 안정성이 있는 코드를 작성할 수 있다.
아이템 49. 콜백에서 this에 대한 타입 제공하기
자바스크립트에서 this 키워드는 매우 혼란스러운 기능이다. let 이나 const 로 선언된 변수가 lexical scope 인 반면, this는 dynamic scope이다. 즉, this 는 정의된 방식이 아니라 호출된 방식에 따라 달라진다. this 바인딩은 종종 콜백 함수에서 쓰인다. 예를 들어, 클래스 내에 onClick 핸들러를 정의한다면 다음처럼 할 수 있다.
class ResetButton {
    render() {
        return makeButton({ text: "Reset", onClick: this.onClick });
    }
    onClick() {
        alert(`Reset ${this}`);
    }
}
그러나 ResetButton 에서 onClick 을 호출하면, this 바인딩 문제로 인해 "Reset is not defined"경고가 뜬다. 가장 원초적인 해결책으로는 생성자에서 메서드에 this 를 바인딩 시키는 것이다.
class ResetButton {
    constructor() {
        this.onClick = this.onClick.bind(this);
    }
    render() {
        return makeButton({ text: "Reset", onClick: this.onClick });
    }
    onClick() {
        alert(`Reset ${this}`);
    }
}
좀 더 간결한 해결책으로, 화살표 함수를 이용할 수 있다.
class ResetButton {
    constructor() {
        this.onClick = this.onClick.bind(this);
    }
    render() {
        return makeButton({ text: "Reset", onClick: this.onClick });
    }
    onClick = () => {
        alert(`Reset ${this}`);
    }
}
문제를 해결할 수 있지만, 화살표 함수로 선언한 경우, ResetButton 의 모든 인스턴스들이 각각 onClick 을 property 로 지니게 된다.  마지막 방법으로, 콜백 함수의 매개변수에 this 를 추가하고, 콜백 함수를 call 로 호출해서 해결할 수 있다.
function addKeyListener(el: HTMLElement, fn: (this: HTMLElement, e: KeyboardEvent) => void) {
    el.addEventListener("keydown", e => {
        fn.call(el, e);
    })
}
아이템 50. 오버로딩 타입보다는 조건부 타입을 사용하기
다음과 같은 double 함수가 있다고 가정해 보자.
 function double(x: number | string): number | string;
 function double(x: any) {
     return x + x; 
 }
선언이 잘못된 것은 아니지만, 모호한 부분이 있다.
const num = double(12);    // string | number
const str = double("x");   // string | number
제네릭 을 사용하면 이러한 동작을 모델링 할 수 있다.
function double<T extends number | string>(x: T): T;
function double(x: any) {
    return x + x;
}
const num = double(12);     // 타입이 12
const str = double("x");    // 타입이 "x"
타입이 구체적으로 좁혀졌지만, 너무 과하게 좁혀졌다. 또한 "x" 를 매개변수로 넘기면, "xx" 가 나오지만 타입 시스템은 "x" 로 리턴 타입을 간주한다. 다른 방법으로, 오버로딩 함수를 이용해서 좀 더 그럴듯하게 문제를 해결할 수 있다.
function double(x: number): number;
function double(x: string): string;
function double(x: any) {
    return x + x;
}
const num = double(12);     // 타입이 number
const str = double("x");    // 타입이 string
함수 타입이 조금 명확해졌지만, 여전히 버그는 남아있다. string 이나 number 타입의 값으로는 잘 동작하지만, 유니온 타입 관련해서 문제가 발생한다. 마지막으로, 조건부 연산자를 이용하면 마지막 문제도 해결할 수 있다.
function double<T extends number | string>(x: T): x extends number ? number : string;
function double(x: any) {
    return x + x;
}
아이템 56. 정보를 감추는 목적으로 private 사용하지 않기
Typescript 에서의 private 키워드는 런타임에서 정보 은닉화를 보장하지 않는다. 가장 대표적인 예로, 다음과 같은 예시를 들 수 있다. 
class Diary {
    private secret = "cheated on English test";
}
const diary = new Diary();
console.log((diary as any).secret); // cheated on English test
정말 정보를 은닉하고 싶다면, private 키워드는 사용하면 안된다. 자바스크립트에서 정보를 감추기 위한 가장 효과적인 방법은 클로저 를 이용하는 것이다.
let counter = (function() {
  let privateCounter = 0;
  function changeBy(val) {
    privateCounter += val;
  }
  return {
    increment: function() {
      changeBy(1);
    },
    decrement: function() {
      changeBy(-1);
    },
    value: function() {
      return privateCounter;
    }
  };
})();
또 하나의 선택지로, # 를 이용하여 타입 체크와 런타임 모두에서 비공개로 만들 수 있다. (2021년 기준으로 비공개 필드는 자바스크립트 표준화 3단계이고, 타입스크립트에서 사용 가능하다)
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.checkPassword("secret"); // false
checker.checkPassword("s3cret"); // true
passwordHash 속성은 클래스 외부에서 접근할 수 없다. 그러나 클로저 기법과 다르게 클래서 메서드나 동일한 클래스의 개별 인스턴스끼리는 접근이 가능하다. 비공개 필드를 지원하지 않는 자바스크립트 버전으로 컴파일하게 되면, WeakMap 을 사용한 구현으로 대체된다.
이것도 읽어보세요