이펙티브 타입스크립트: 7장 코드를 작성하고 실행하기

이펙티브 타입스크립트 북스터디 6장 아이템 53-57


아이템 53. 타입스크립트 기능보다는 ECMAScript 기능을 사용하기

요약

  • 일반적으로 타입스크립트 코드에서 모든 타입 정보를 제거하면 자바스크립트가 되지만, 열거형, 매개변수 속성, 트리플 슬래시 임포트, 데코레이터는 타입 정보를 제거한다고 자바스크립트가 되지는 않는다.
  • 타입스트립트의 역할을 명확하게 하려면, 열거형, 매개변수 속성, 트리플 슬래시 임포트, 데코레이터는 사용하지 않는 것이 좋다.

설명

※ 타입스크립트의 원칙(역할) = 런타임 기능이 아닌, 오직 타입 기능만 발전시킨다.

  • 타입스크립트가 만들어지기 시작한 2010년경에는 자바스크립트가 지금보다 더 결함이 많은 언어였다. 이 시기의 초기 타입스크립트는 그 결함을 보완하기 위해 자바스크립트에 없던 기능들(열거형, 클래스, 모듈 시스템)을 만들었다.
  • 시간이 지나면서 TC39(자바스크립트 표준 기구)가 내장 기능을 추가해왔고, 이 신규 기능들은 초기 타입스크립트가 독자적으로 개발한 기능과 호환성 문제를 일으켰다.
  • 그래서 타입스크립트는 초기 버전과의 호환성을 버리고, TC39는 런타임 기능을 발전시키고, 타입스크립트 팀은 타입 기능만 발전시킨다는 원칙을 세웠다.
  • 이 원칙을 세우기 이전에 사용되고 있던 기능들(열거형, 매개변수 속성, 트리플 슬래시 임포트, 데코레이터)이 아직 남아있다. 이 기능들은 타입 공간(타입스크립트)과 값 공간(자바스크립트)의 경계를 혼란스럽게 만들기 때문에 사용하지 않는 것이 좋다.
  1. 열거형(enum)

    //ts
    enum Flavor {
      VANILLA = 0,
      CHOCOLATE = 1,
      STRAWBERRY = 2,
    }
    
    Flavor; // 자동완성 -> VANILLA, CHOCOLATE, STRAWBERRY;
    Flavor[0]; // VANILLA
    
    let flavor = Flavor.CHOCOLATE; // type: Flavor
    
    //-------- js ---------
    var Flavor;
    (function (Flavor) {
        Flavor[Flavor["VANILLA"] = 0] = "VANILLA";
        Flavor[Flavor["CHOCOLATE"] = 1] = "CHOCOLATE";
        Flavor[Flavor["STRAWBERRY"] = 2] = "STRAWBERRY";
    })(Flavor || (Flavor = {}));
    
    Flavor; // 자동완성 -> VANILLA, CHOCOLATE, STRAWBERRY;
    Flavor[0]; // VANILLA
    let flavor = Flavor.CHOCOLATE; // type: Flavor
    • 타입스크립트 열거형이 가진 문제점
      • 숫자 열거형에 0, 1, 2 외의 다른 숫자가 할당되면 매우 위험하다. (enum은 비트 플래그 구조를 표현하기 위해 설계되었으므로 어떤 결과를 나타낼지 모른다.)

      • 상수 열거형은 보통의 열거형과 달리 런타임에 완전히 제거된다.const enum Flavor로 바꾸면, 컴파일러는 Flavor.CHOCOLATE을 1으로 바꾼다.

        //ts
        const enum Flavor2 {
          VANILLA = 0,
          CHOCOLATE = 1,
          STRAWBERRY = 2,
        }
        
        let flavor2 = Flavor2.CHOCOLATE;
        
        //-------- js ---------
        
        let flavor2 = 1 /* Flavor2.CHOCOLATE */;
      • 위처럼 정보가 사라져 버리는 것을 막기위해 preserveConstEnums 플래그를 설정한다. 이 상태에서 상수 열거형은 보통의 열거형처럼 런타임 코드에 정보를 유지한다.

        //"preserveConstEnums": true 설정 후 컴파일
        //-------- js ---------
        var Flavor2;
        (function (Flavor2) {
            Flavor2[Flavor2["VANILLA"] = 0] = "VANILLA";
            Flavor2[Flavor2["CHOCOLATE"] = 1] = "CHOCOLATE";
            Flavor2[Flavor2["STRAWBERRY"] = 2] = "STRAWBERRY";
        })(Flavor2 || (Flavor2 = {}));
        let flavor2 = 1 /* Flavor2.CHOCOLATE */;
      • 문자열 열거형은 런타임의 타입 안전성과 투명성을 제공한다.그러나 다른 타입과 달리 구조적 타이핑이 아닌 명목적 타이핑을 사용한다. 즉, 타입이름을 똑같이 써줘야 한다.

        (구조적 타이핑: 구조가 같은 할당 허용 ↔ 명목적 타이핑: 타입의 이름이 같아야 할당 허용)

        //ts
        enum Flavor3 {
          VANILLA = 'vanilla',
          CHOCOLATE = 'chocolate',
          STRAWBERRY = 'strawberry',
        }
        
        let flavor3 = Flavor3.CHOCOLATE; //type Flavor
        flavor3 = 'strawberry'; //❌ '"strawberry"' 형식은 'Flavor3' 형식에 할당할 수 없습니다.
        
        function scoop(flavor: Flavor) {
          /*...*/
        }
        
        scoop('vanilla'); //❌ '"vanilla"' 형식의 인수는 'Flavor' 형식의 매개 변수에 할당될 수 없습니다.
        
        scoop(Flavor.VANILLA); //이렇게 써 줘야 통과
        
        //-------- js ---------
        var Flavor3;
        (function (Flavor3) {
            Flavor3["VANILLA"] = "vanilla";
            Flavor3["CHOCOLATE"] = "chocolate";
            Flavor3["STRAWBERRY"] = "strawberry";
        })(Flavor3 || (Flavor3 = {}));
        
        let flavor3 = Flavor3.CHOCOLATE;
        
        flavor3 = 'strawbrry';
        function scoop(flavor) {
            /*...*/
        }
        
        scoop('vanilla'); //✅ 자바스크립트에서는 정상
        
        scoop(Flavor.VANILLA);
    • ✅ 열거형 대신 리터럴 타입의 유니온을 사용하자.
      • 열거형 만큼 안전하고 자바스크립트와 호환되며 자동완성 기능을 사용할 수 있다.
      //ts
      type Flavor4 = "vanilla" | "chocolate" | "strawberry";
      let flavor4: Flavor4 = "chocolate";  //✅ 정상
      flavor4 = 'mint chip'; //'"mint chip"' 형식은 'Flavor4' 형식에 할당할 수 없습니다
      
      //-------- js ---------
      // enum Flavor {
      //   VANILLA = 0,
      //   CHOCOLATE = 1,
      //   STRAWBERRY = 2,
      // }
      let flavor4 = "chocolate";
      flavor4 = 'mint chip';
  2. 매개변수 속성

    • 클래스를 초기화할 때 속성을 할당하기 위해 생성자의 매개변수 사용한다. 타입스크립트는 더 간결한 문법을 제공한다.

      // 클래스를 초기화할 때 속성을 할당하기 위해 생성자의 매개변수 사용
      class Person {
        name: string;
        constructor(name: string) {
          this.name: name;
        }
      }
      
      //ts는 더 간결한 문법 제공
      class Person {
        constructor(public name: string) {}  // public name -> 매개변수 속성
      }
      
      //-------- js ---------
      class Person {
          constructor(name) {
              this.name = name;
          } //컴파일시 자동으로 초기화 코드가 생김
      }
    • 위 코드에서 public name 을 매개변수 속성이라고 부른다.

    • 매개변수 속성의 문제점

      • 일반적으로 타입스크립트 컴파일은 타입 제거가 이루어지므로 코드가 줄어들지만, 매개변수 속성은 코드가 늘어나는 문법이다.

      • 매개변수 속성이 런타임에는 실재로 사용되지만 , 타입스크립트 관점에서는 사용되지 않는 것처럼 보인다.

      • 매개변수 속성과 일반 속성을 섞어서 사용하면 클래스의 설계가 혼란스러워진다.

        //name, first, last 세가지 속성이 있지만
        //first, last는 속성이고 name은 매개변수 속성으로 일관적이지 않다.
        class Person {
          first: string;
          last: string;
          constructor(public name: string) {
            [this.first, this.last] = name.split(' ');
          }
        }
    • ✅ 일반속성과 매개변수 속성을 동시에 사용하면 설계가 혼란스러워지기 때문에 한가지만 사용하는 것이 좋다.

  3. 네임스페이스와 트리플 슬래시 임포트

    • 타입스크립트는 자체적으로 모듈 시스템을 구축하면서 module 키워드와 트리플 슬래시/// 임포트를 사용했다. 하지만 ECMAScript 2015에서 module를 사용하자 module과 동일한 기능을하는 namespace 키워드를 추가했다.

      namespace foo {
        function bar() {}
      }
      /// <reference path="other.ts" />
      foo.bar();
    • ✅ module과 ///는 호환성을 위해 남아있는 것일뿐, import export 를 사용해야 한다.

  4. 데코레이터

    • 데코레이터를 사용하려면 experimentalDecorators 속성을 설정해주어야 한다.

      // "experimentalDecorators": true 로 설정 필요
      
      class Greeter {
        greeting: string;
        constructor(message: string) {
          this.greeting = message;
        }
        @logged
        greet() {
          return 'Hello, ' + this.greeting;
        }
      }
      
      function logged(target: any, name: string, descriptor: PropertyDescriptor) {
        const fn = target[name];
        descriptor.value = function () {
          console.log(`Calling ${name}`);
          return fn.apply(this, arguments);
        };
      }
      
      console.log(new Greeter('Dave').greet());
      //출력;
      //Calling greet
      //Hello, Dave
    • 현재까지도 표준화가 완료되지 않았기 때문에 사용중인 데코레이터가 비표준으로 바뀌거나 호환성이 깨질 가능성이 있다.

    • ✅ 데코레이터가 표준이 되기 전에는 타입스크립트에서 데코레이터를 사용하지 않는 것이 좋다.


아이템 54. 객체를 순회하는 노하우

요약

  • 객체를 순회할 때, 키가 어떤 타입인지 정확히 파악하고 있다면 let k: keyof T 와 for-in 루프를 사용한다. 함수의 매개변수로 쓰이는 객체에는 추가적인 키가 존재할 수 있다는 점을 명심하자.
  • 객체를 순회하며 키와 값을 얻는 가장 일반적인 방법은 Object.extries를 사용하는 것이다.

설명

  1. 객체를 순회할 때, 키가 어떤 타입인지 정확히 파악하고 있다면 let k: keyof T 와 for-in 루프를 사용한다.

    interface AB {
      a: string;
      b: number;
    }
    function foo(ab: AB) {
      for (const k in ab) {
        const v = ab[k]; //❌ k와 ab 객체의 키 타입이 서로 다르게 추론되어 에러 발생! v는 암시적 any로 설정됨.
      }
    }
    
    // ✅ let k: keyof T와 for-in 루프 사용 (정확한 타입 추론)
    function foo(ab: AB) {
      let k: keyof AB;
      for(k in ab) {  //let k: "a" | "b"
        const v = ab[k];   //string | number 타입
      }
    }
    
    // ✅ 쉽게 해결하는 가장 일반적인 방법은 Object.entries 
    //-> 키와 값 타입 다루기 까다로워지나, 구조적 타이핑 문제는 해결
    function foo0(ab: AB) {
      for(const [k, v] of Object.entries(ab)) {  //let k: "a" | "b"
        k; //string
        v; //any
      }
    }
  2. 함수의 매개변수로 쓰이는 객체에는 추가적인 키가 존재할 수 있다는 점을 명심하자.

    • 타입스크립트는 구조적 타이핑이기 때문에 할당가능한 다른 값이 모두 들어올 수 있다.
    • 이 경우, v 가 string | number 타입으로 추론된 것은 문제가 될 수 있다.
    const abc = {
      a: "aaa",
      b: 2,
      c: new Date()
    }
    
    function foo(ab: AB) {
      let k: keyof AB;
      for(k in ab) {  //let k: "a" | "b"
        const v = ab[k];   //string | number 타입
      }
    }
    
    foo(abc);  //정상 (구조적 타이핑)
  3. 골치아픈 타입 문제 없이, 단지 객체의 키와 값을 순회하고 싶을때는 Object.entries를 사용한다.

    
    // ✅ 쉽게 해결하는 가장 일반적인 방법은 Object.entries 
    //-> 키와 값 타입이 넓게 추론되나, 구조적 타이핑 문제는 해결
    function foo0(ab: AB) {
      for(const [k, v] of Object.entries(ab)) {
        k; //string
        v; //any
      }
    }

아이템 55. DOM 계층 구조 이해하기

요약

  • DOM에는 타입 계층 구조가 있다. DOM타입은 타입 스크립트에서 중요한 정보이며, 브라우저 관련 프로젝트에서 타입 스크립트를 사용할 때 매우 유용하다.
  • Node, Element, HTMLElement, EventTarget 간의 차이점, 그리고 Event와 MouseEvent의 차이점을 알아야 한다.
  • DOM 엘리먼트와 이벤트에는 충분히 구체적인 타입 정보를 사용하거나, 타입스크립트가 추론할 수 있도록 문맥 정보를 활용해야 한다

설명

  1. DOM 타입 계층 구조 → 각 타입마다 가지고 있는 속성이 다르다.

    ko.javascript.info

    • EventTarget
      • DOM 타입 중 가장 추상화된 타입
      • 이벤트 리스너 추가 제거, 이벤트 보내기만 가능하다.
    • Node
      • Element 의 상위 타입. text와 주석도 Node이다.
    • Element
      • HTMLElement 와 SVGElement 로 구성되어있다.
    • HTMLxxxxElement
      • 이 타입의 특정 엘리먼트들은 자신만의 고유 속성을 가지고 있다.
      • ex) ImageElement의 src 속성, HTMLInputElement 의 value 속성 등
      • 각 엘리먼트의 고유 속성에 접근하기 위해서 구체적으로 타입을 지정해야 한다.
  2. DOM 엘리먼트와 이벤트에 충분히 구체적인 타입 정보를 사용하기

    • 일반적으로 다음 코드의 경우에는 구체적인 DOM 타입을이 추론된다. (태그 정보를 얻을 수 없는 경우)

      const el_1 = document.getElementsByTagName('p')[0];   //HTMLParagraphElement
      const el_2 = document.createElement('button')   //HTMLButtonElement
      const el_3 = document.querySelector('div')   //HTMLDivElement | null
    • 하지만 태그 정보를 추론할 수 없는 경우에는 구체적인 타입 추론이 되지않는다. (태그 정보를 얻을 수 없는 경우)

      document.getElementById('my-div');  //HTMLElement | null
    • 일반적으론 타입 단언문을 지양해야 하지만 DOM 관련해서는 타입스크립트보다 우리가 더 정확히 알고 있으므로 단언문을 사용해도 좋다.

      document.getElementById('my-div') as HTMLDivElement;
    • 또한 strictNullChecks 가 되어있는 상태라면 null 체크를 위해 if 분기문을 추가해주어야 한다.

      const element = document.getElementById('my-div') //HTMLElement | null
      
      if(element) {
        // ... 
      }
  3. Event 타입에도 별도의 계층 구조가 있고 각 타입마다 고유의 속성을 가지고 있다.

    • 이벤트 타입 종류

      • UIEvent: 모든 종류의 사용자 인터페이스 이벤트
      • MouseEvent: 마우스로부터 발생되는 이벤트
      • TouchEvent: 모바일 기기의 터치 이벤트
      • WheelEvent: 스크롤 휠로부터 발생되는 이벤트
      • KeyboardEvent: 키 누름 이벤트
    • 이벤트 타입도 구체적으로 지정해야 고유 속성을 사용할 수 있다.

      function handleDrag(eDown: Event) {
        const dragStart = [eDown.clientX, eDown.clientY]; //❌ 'Event' 형식에 'clientY' 속성이 없습니다.
      	...
      }
      
      //정확한 이벤트 타입 지정 시
      function handleDrag(eDown: MouseEvent) {
        const dragStart = [eDown.clientX, eDown.clientY]; //✅
      	...
      }
    • addEventHandler 를 통해 어떤 이벤트인지 명시한 경우, 이벤트 타입이 구체적으로 추론된다.

      function addDragHandler(eDown: Event) {
        const targetEl = eDown.currentTarget; //EventTarget | null
        el_1.addEventListener('mousedown', eDown => {
          eDown; //MouseEvent
          const dragStart = [eDown.clientX, eDown.clientY];
        })
      }

아이템 56. 정보를 감추는 목적으로 private 사용하지 않기

요약

  • public, protected, private 접근 제어자는 타입 시스템에서만 강제될 뿐입니다. 런타임에서는 소용이 없으며 단언문을 통해 우회할 수 있다. 접근 제어자로 데이터를 감추려고 해서는 안 된다.
  • 확실히 데이터를 감추고 싶다면 클로저를 사용해야 합니다.

설명

  1. 타입스크립트에서는 public, protected, private 접근 제어자를 사용할 수 있다. 하지만 타입스크립트 키워드이기 때문에 컴파일 후 사라진다.
    • 접근 제어자는 컴파일 시점에만 오류를 표시할 뿐, 런타임에는 아무런 효력이 없다.

    • 또한 단언문을 사용하면 ts 에서도 private 속성에 접근할 수 있다.

    • 정보를 감추기 위해 private을 사용하면 안 된다.

      //ts
      class PasswordChecker {
        constructor(private passwordHash: number) {
          this.passwordHash = passwordHash;
        }
      }
      const checker = new PasswordChecker(hash("s3cret"));
      checker.passwordHash; // 타입스크립트에서만 에러
      (checker as any).passwordHash; // 정상
      
      //-------- js ---------
      "use strict";
      class PasswordChecker {
          constructor(passwordHash) {
              this.passwordHash = passwordHash;
              this.passwordHash = passwordHash;
          }
      }
      const checker = new PasswordChecker(hash("s3cret"));
      checker.passwordHash; // 타입스크립트에서만 오류
      checker.passwordHash; 
      
  2. 정보를 숨기기 위해 사용할 수 있는 방법은 2가지가 있다.
    • 클로저

      • 클로저의 경우 생성자 외부에서 접근할 수 없으며, 인스턴스를 생성할 때마다 각 메서드의 복사본이 생성되기 때문에(메서드 정의가 생성자 내부에 존재하는 경우) 메모리를 낭비하게 된다.
      • 또한 동일한 클래스의 개별 인스턴스끼리도 서로의 비공개 데이터에 접근하는 것이 불가능하기 때문에 철저하게 비공개이면서 동시에 불편함이 따른다.
      declare function hash(text: string): number;
      
      class PasswordChecker {
        checkPassword: (password: string) => boolean;
        constructor(passwordHash: number) {
          this.checkPassword = (password: string) => {
            return hash(password) === passwordHash;
          }; //passwordHash 접근 불가
        }
      }
      const checker = new PasswordChecker(hash("s3cret"));
      checker.checkPassword("s3cret");
    • 접두사로 #을 붙여 비공개 필드 사용

      • 타입 체크와 런타임 모두에서 비공개로 만들 수 있다.
      • 비공개 필드는 클래스 메서드나 동일한 클래스의 개별 인스턴스끼리 접근이 가능하다.
      • 비공개 필드를 지원하지 않는 자바스크립트 버전으로 컴파일하게 되면 WeapMap을 사용한 구현으로 대체된다.
      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; // 에러. 클래스 본문 외부에서 프라이빗 식별자를 사용할 수 없습니다.

아이템 57. 소스맵으로 타입스크립트 디버깅하기

요약

  • 원본 코드가 아닌 변환된 자바스크립트 코드를 디버깅하지 말자. 소스맵을 사용해서 런타임에 타입스크립트 코드를 디버깅한다.
  • 소스맵이 최종적으로 변환된 코드에 완전히 매핑되었는지 확인한다.
  • 소스맵에 원본 코드가 그대로 포함되도록 설정되어 있을 수 있다. 공개되지 않도록 설정을 확인한다.

설명

타입스크립트 코드를 실행한다는 것은 타입스크립트 컴파일러가 생성한 자바스크립트 코드를 실행한다는 것을 의미한다. 디버거는 런타임에 동작하며 컴파일된 자바스크립트 코드가 어떻게 생성된것인지 알지 못한다. 변환된 자바스크립트 코드는 복잡해서 디버깅이 매우 어렵다.

  • 이런 상황에서 코드의 어디가 잘못되었는지 확인하려면 소스맵이 필요하다.
  • 소스맵은 변환된 코드의 위치와 심벌들을 원본 코드의 원래 위치와 심벌들로 매핑한다.
  • tsconfig의 compilerOptions에서 "sourceMap": true 설정을 해주면 타입스크립트가 소스맵을 생성할 수 있다.
  • 사용 예시를 확인하기 위해 다음 영상을 참고하면 좋다. Webpack & TypeScript Setup #6 - Source Maps

디지엠유닛원 주식회사

  • 대표이사 권혁태
  • 개인정보보호책임자 정경영
  • 사업자등록번호 252-86-01619
  • 주소
    서울특별시 금천구 가산디지털1로 83, 6층 601호(가산동, 파트너스타워)
  • 이메일 unit1@dgmit.com